@homeflare/alchemy 0.6.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 (120) hide show
  1. package/README.md +54 -10
  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 +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/index.js.map +2 -2
  53. package/dist/launchd/secret-tripwire.d.ts +1 -0
  54. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  55. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  56. package/dist/openbao/fake-engines.d.ts +30 -0
  57. package/dist/openbao/fake-engines.d.ts.map +1 -0
  58. package/dist/openbao/fake-stack.d.ts +29 -0
  59. package/dist/openbao/fake-stack.d.ts.map +1 -0
  60. package/dist/openbao/index.js +231 -151
  61. package/dist/openbao/index.js.map +9 -7
  62. package/dist/openbao/policy.d.ts +2 -0
  63. package/dist/openbao/policy.d.ts.map +1 -1
  64. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  65. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  67. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename.d.ts +106 -0
  70. package/dist/openbao/rename.d.ts.map +1 -0
  71. package/docs/caddy.md +180 -0
  72. package/docs/mesh-node.md +188 -0
  73. package/package.json +7 -2
  74. package/src/caddy/admin-calls.ts +128 -0
  75. package/src/caddy/admin-guard.ts +170 -0
  76. package/src/caddy/admin.ts +74 -0
  77. package/src/caddy/caddyfile-tripwire.ts +133 -0
  78. package/src/caddy/config-form.ts +64 -0
  79. package/src/caddy/config-lifecycle.test.ts +218 -0
  80. package/src/caddy/config-lifecycle.ts +148 -0
  81. package/src/caddy/config.ts +116 -0
  82. package/src/caddy/digest.ts +44 -0
  83. package/src/caddy/fake-caddy.ts +160 -0
  84. package/src/caddy/guards.test.ts +156 -0
  85. package/src/caddy/index.ts +25 -0
  86. package/src/caddy/local-admin.test.ts +160 -0
  87. package/src/caddy/local-admin.ts +189 -0
  88. package/src/caddy/providers.test.ts +244 -0
  89. package/src/caddy/providers.ts +17 -0
  90. package/src/caddy/with-file.ts +62 -0
  91. package/src/cloudflare/fake-mesh.ts +181 -0
  92. package/src/cloudflare/index.ts +9 -3
  93. package/src/cloudflare/mesh-node-api.ts +127 -0
  94. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  95. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  96. package/src/cloudflare/mesh-node-form.ts +115 -0
  97. package/src/cloudflare/mesh-node-harness.ts +124 -0
  98. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  99. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  100. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  101. package/src/cloudflare/mesh-node-token.ts +125 -0
  102. package/src/cloudflare/mesh-node.test.ts +172 -0
  103. package/src/cloudflare/mesh-node.ts +120 -0
  104. package/src/cloudflare/providers.ts +20 -2
  105. package/src/index.ts +1 -0
  106. package/src/launchd/secret-tripwire.ts +1 -1
  107. package/src/openbao/REPLACE.md +97 -21
  108. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  109. package/src/openbao/cloudflare-role.ts +30 -8
  110. package/src/openbao/fake-engines.ts +119 -0
  111. package/src/openbao/fake-stack.ts +112 -0
  112. package/src/openbao/policy-rename.test.ts +112 -0
  113. package/src/openbao/policy-wire.test.ts +7 -26
  114. package/src/openbao/policy.ts +27 -4
  115. package/src/openbao/proxmox-role-form.ts +27 -8
  116. package/src/openbao/proxmox-role-wire.ts +24 -0
  117. package/src/openbao/proxmox-role.ts +62 -37
  118. package/src/openbao/rename-occupied.test.ts +166 -0
  119. package/src/openbao/rename.ts +162 -0
  120. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Moving a Bao.CloudflareRole or a Bao.ProxmoxRole to a new `mount` or `name`, through Alchemy's own
3
+ * plan and apply (fake-stack.ts) against fakes of each engine's role store (fake-engines.ts). Before
4
+ * 2026-09-21 every move below planned `update` and left the old role minting.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import * as Output from 'alchemy/Output';
9
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import { groupKey } from './cloudflare-group-scope.ts';
14
+ import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
15
+ import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
16
+ import type { BaoCloudflareRoleProps } from './cloudflare-role-form.ts';
17
+ import type { Seen } from './fake-bao.ts';
18
+ import { type Store, cloudflareRoles, proxmoxRoles } from './fake-engines.ts';
19
+ import { type StackBody, withFakeStack, writesOf as writes } from './fake-stack.ts';
20
+ import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
21
+ import type { BaoProxmoxRoleProps } from './proxmox-role-form.ts';
22
+
23
+ /** Every group resolves, to an id named after it — this file is about paths, not groups. */
24
+ const groups = Layer.succeed(CloudflarePermissionGroups, {
25
+ resolve: (_mount, refs) =>
26
+ Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
27
+ });
28
+ const providers = Layer.mergeAll(
29
+ BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
30
+ BaoProxmoxRoleProvider(),
31
+ FetchHttpClient.layer,
32
+ );
33
+
34
+ type Removal = typeof RemovalPolicy.destroy;
35
+
36
+ const withStack = (
37
+ store: Store,
38
+ body: (deploy: (body: StackBody) => Promise<unknown>, seen: Seen[]) => Promise<void>,
39
+ ) => withFakeStack(providers, store, (stack, bao) => body(stack.deploy, bao.seen));
40
+
41
+ const livePaths = (store: Store) => [...store.live.keys()].sort();
42
+
43
+ const CF: BaoCloudflareRoleProps = {
44
+ description: 'Read DNS records on one zone. (zone: example.com)',
45
+ maxTtl: '1h',
46
+ mount: 'cloudflare-acme-dns',
47
+ name: 'example-com-dns-read',
48
+ policies: [
49
+ {
50
+ effect: 'allow',
51
+ groupOrder: 'declared',
52
+ groups: ['DNS Read'],
53
+ resource: 'com.cloudflare.api.account.zone.z1',
54
+ },
55
+ ],
56
+ ttl: '5m',
57
+ };
58
+ const cf = (props: Partial<BaoCloudflareRoleProps>, removal: Removal = RemovalPolicy.retain) =>
59
+ Effect.asVoid(BaoCloudflareRole('Role', { ...CF, ...props }).pipe(removal()));
60
+
61
+ describe('Bao.CloudflareRole move, through the engine', () => {
62
+ it('a new name plans replace and deletes the OLD path under destroy', async () => {
63
+ const store = cloudflareRoles();
64
+ await withStack(store, async (deploy, seen) => {
65
+ await deploy(cf({}, RemovalPolicy.destroy));
66
+ seen.length = 0;
67
+ assert.deepEqual(await deploy(cf({ name: 'example-com-dns-edit' }, RemovalPolicy.destroy)), {
68
+ Role: 'replace',
69
+ });
70
+ assert.deepEqual(writes(seen), [
71
+ 'PUT /v1/cloudflare-acme-dns/roles/example-com-dns-edit',
72
+ 'DELETE /v1/cloudflare-acme-dns/roles/example-com-dns-read',
73
+ ]);
74
+ assert.deepEqual(livePaths(store), ['cloudflare-acme-dns/roles/example-com-dns-edit']);
75
+ });
76
+ });
77
+
78
+ it('a new mount plans replace, and the default retain keeps the old role live', async () => {
79
+ const store = cloudflareRoles();
80
+ await withStack(store, async (deploy, seen) => {
81
+ await deploy(cf({}));
82
+ assert.deepEqual(await deploy(cf({ mount: 'cloudflare-other-dns' })), { Role: 'replace' });
83
+ assert.ok(!writes(seen).some((each) => each.startsWith('DELETE')));
84
+ assert.deepEqual(livePaths(store), [
85
+ 'cloudflare-acme-dns/roles/example-com-dns-read',
86
+ 'cloudflare-other-dns/roles/example-com-dns-read',
87
+ ]);
88
+ });
89
+ });
90
+
91
+ it('a trailing slash on the mount is the same path: noop, nothing deleted', async () => {
92
+ const store = cloudflareRoles();
93
+ await withStack(store, async (deploy, seen) => {
94
+ await deploy(cf({}, RemovalPolicy.destroy));
95
+ seen.length = 0;
96
+ const planned = await deploy(cf({ mount: 'cloudflare-acme-dns/' }, RemovalPolicy.destroy));
97
+ assert.deepEqual(planned, { Role: 'noop' });
98
+ assert.deepEqual(writes(seen), []);
99
+ });
100
+ });
101
+ });
102
+
103
+ const PVE: BaoProxmoxRoleProps = {
104
+ maxTtl: '6h',
105
+ mintUser: 'reader@pve',
106
+ mount: 'proxmox-lab',
107
+ name: 'read',
108
+ ttl: '1h',
109
+ };
110
+ const pve = (props: Partial<BaoProxmoxRoleProps>, removal: Removal = RemovalPolicy.retain) =>
111
+ Effect.asVoid(BaoProxmoxRole('Role', { ...PVE, ...props }).pipe(removal()));
112
+
113
+ describe('Bao.ProxmoxRole move, through the engine', () => {
114
+ it('a new name plans replace and deletes the OLD path under destroy', async () => {
115
+ const store = proxmoxRoles();
116
+ await withStack(store, async (deploy, seen) => {
117
+ await deploy(pve({}, RemovalPolicy.destroy));
118
+ seen.length = 0;
119
+ assert.deepEqual(await deploy(pve({ name: 'reader' }, RemovalPolicy.destroy)), {
120
+ Role: 'replace',
121
+ });
122
+ assert.deepEqual(writes(seen), [
123
+ 'PUT /v1/proxmox-lab/roles/reader',
124
+ 'DELETE /v1/proxmox-lab/roles/read',
125
+ ]);
126
+ assert.deepEqual(livePaths(store), ['proxmox-lab/roles/reader']);
127
+ });
128
+ });
129
+
130
+ it('refuses a rename that also re-scopes mint_user, at plan, before any write', async () => {
131
+ const store = proxmoxRoles();
132
+ await withStack(store, async (deploy, seen) => {
133
+ await deploy(pve({}));
134
+ seen.length = 0;
135
+ const moved = pve({ mintUser: 'provisioner@pve', name: 'provision' });
136
+ await assert.rejects(deploy(moved), /allowMintUserChange: 'reader@pve'/);
137
+ assert.deepEqual(writes(seen), []);
138
+ assert.deepEqual(livePaths(store), ['proxmox-lab/roles/read']);
139
+ });
140
+ });
141
+
142
+ it('lets the re-scoping rename through when it names the mint user it replaces', async () => {
143
+ const store = proxmoxRoles();
144
+ await withStack(store, async (deploy) => {
145
+ await deploy(pve({}));
146
+ const allowed = pve({
147
+ allowMintUserChange: 'reader@pve',
148
+ mintUser: 'provisioner@pve',
149
+ name: 'provision',
150
+ });
151
+ assert.deepEqual(await deploy(allowed), { Role: 'replace' });
152
+ assert.equal(store.live.get('proxmox-lab/roles/provision')?.['mint_user'], 'provisioner@pve');
153
+ });
154
+ });
155
+
156
+ it('keeps a same-path mint_user change an update, refused at apply as before', async () => {
157
+ const store = proxmoxRoles();
158
+ await withStack(store, async (deploy, seen) => {
159
+ await deploy(pve({}));
160
+ seen.length = 0;
161
+ await assert.rejects(deploy(pve({ mintUser: 'provisioner@pve' })), /re-scopes mint_user/);
162
+ assert.deepEqual(writes(seen), []);
163
+ });
164
+ });
165
+ });
166
+
167
+ /**
168
+ * ★ The name is an Output of an upstream role whose `ttl` is changing, so no diff can see it at plan.
169
+ * Reconcile refuses the `update`; the next deploy, with the value settled, plans `replace`.
170
+ */
171
+ describe('an update across a move the diff could not see', () => {
172
+ it('Bao.CloudflareRole: refused before any write, then replaced', async () => {
173
+ const store = cloudflareRoles();
174
+ const body = (ttl: string): StackBody =>
175
+ Effect.gen(function* () {
176
+ const up = yield* BaoCloudflareRole('Upstream', { ...CF, name: 'upstream', ttl });
177
+ const name = Output.interpolate`role-${up.ttl}`;
178
+ yield* BaoCloudflareRole('Role', { ...CF, name }).pipe(RemovalPolicy.destroy());
179
+ });
180
+ await withStack(store, async (deploy) => {
181
+ await deploy(body('5m'));
182
+ await assert.rejects(deploy(body('10m')), /identity moved from .*role-300 to .*role-600/);
183
+ assert.ok(!store.live.has('cloudflare-acme-dns/roles/role-600'));
184
+ assert.deepEqual(await deploy(body('10m')), { Role: 'replace', Upstream: 'noop' });
185
+ assert.ok(!store.live.has('cloudflare-acme-dns/roles/role-300'));
186
+ });
187
+ });
188
+
189
+ it('Bao.ProxmoxRole: refused before any write, then replaced', async () => {
190
+ const store = proxmoxRoles();
191
+ const body = (ttl: string): StackBody =>
192
+ Effect.gen(function* () {
193
+ const up = yield* BaoProxmoxRole('Upstream', { ...PVE, name: 'upstream', ttl });
194
+ const name = Output.interpolate`r-${up.ttl}`;
195
+ yield* BaoProxmoxRole('Role', { ...PVE, name }).pipe(RemovalPolicy.destroy());
196
+ });
197
+ await withStack(store, async (deploy) => {
198
+ await deploy(body('1h'));
199
+ await assert.rejects(deploy(body('2h')), /identity moved from .*r-1h to .*r-2h/);
200
+ assert.ok(!store.live.has('proxmox-lab/roles/r-2h'));
201
+ assert.deepEqual(await deploy(body('2h')), { Role: 'replace', Upstream: 'noop' });
202
+ assert.deepEqual(livePaths(store), ['proxmox-lab/roles/r-2h', 'proxmox-lab/roles/upstream']);
203
+ });
204
+ });
205
+ });