@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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The Alchemy wiring: the provider built as a Layer over a fake admin API, its handlers called the
3
+ * way the engine calls them; caddyWithFile's registration; and the barrel, the public API.
4
+ */
5
+ import { afterEach, describe, expect, test } from 'bun:test';
6
+ import * as Output from 'alchemy/Output';
7
+ import { Stack } from 'alchemy/Stack';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+ import { caddyAdminLayer } from './admin.ts';
11
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
12
+ import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
13
+ import * as barrel from './index.ts';
14
+ import { localCaddyAdmin } from './local-admin.ts';
15
+ import { caddyWithFile } from './with-file.ts';
16
+
17
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
18
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
19
+
20
+ let fake: FakeCaddy | undefined;
21
+ afterEach(() => {
22
+ fake?.stop();
23
+ fake = undefined;
24
+ });
25
+
26
+ /** A handler the provider must define; a missing one fails the test by name. */
27
+ const handler = <F>(name: string, fn: F | undefined): F => {
28
+ if (fn === undefined) throw new Error(`provider has no ${name} handler`);
29
+ return fn;
30
+ };
31
+
32
+ const withProvider = <A>(
33
+ use: (p: Effect.Success<typeof CaddyConfig.Provider>) => Effect.Effect<A, unknown>,
34
+ address?: string,
35
+ ) => {
36
+ // ★ A Caddy on its default :2019 behind a forward, as in config-lifecycle.test.ts.
37
+ fake =
38
+ address === undefined
39
+ ? fakeCaddy({ listenPort: 2019, running: { apps: { legacy: true } } })
40
+ : undefined;
41
+ const admin = localCaddyAdmin({
42
+ address: address ?? (fake as FakeCaddy).address,
43
+ hostHeader: '127.0.0.1:2019',
44
+ retries: 0,
45
+ });
46
+ return Effect.runPromise(
47
+ Effect.gen(function* () {
48
+ return yield* use(yield* CaddyConfig.Provider);
49
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin))))),
50
+ );
51
+ };
52
+
53
+ describe('CaddyConfigProvider', () => {
54
+ test('reconcile applies over its own state; delete then sends nothing at all', async () => {
55
+ await withProvider((provider) =>
56
+ Effect.gen(function* () {
57
+ const output = yield* provider.reconcile({
58
+ ...ids,
59
+ bindings: [] as never,
60
+ news: { caddyfile: SITE },
61
+ olds: { caddyfile: SITE },
62
+ // ★ State from an earlier deploy: the running `legacy` config is this stack's to replace.
63
+ output: { configSha256: 'earlier', endpoint: fake?.address ?? '' },
64
+ session: undefined as never,
65
+ });
66
+ expect(fake?.seen.some((call) => call.path === '/load')).toBe(true);
67
+ const before = fake?.seen.length;
68
+ yield* provider.delete({ ...ids, olds: { caddyfile: SITE }, output } as never);
69
+ // ★ Delete never unloads, never stops: not one more call reached Caddy.
70
+ expect(fake?.seen.length).toBe(before);
71
+ }),
72
+ );
73
+ });
74
+
75
+ test('diff defers to the engine with no prior output', async () => {
76
+ const diff = await withProvider((provider) =>
77
+ handler(
78
+ 'diff',
79
+ provider.diff,
80
+ )({
81
+ ...ids,
82
+ news: { caddyfile: SITE },
83
+ olds: { caddyfile: SITE },
84
+ output: undefined,
85
+ } as never),
86
+ );
87
+ expect(diff).toBeUndefined();
88
+ });
89
+
90
+ test('an unresolved sourceFile still validates the Caddyfile at plan time', async () => {
91
+ const pending = Effect.succeed('/usr/local/etc/Caddyfile');
92
+ const output = { configSha256: 'x', endpoint: 'y' };
93
+ const ok = await withProvider((provider) =>
94
+ handler(
95
+ 'diff',
96
+ provider.diff,
97
+ )({
98
+ ...ids,
99
+ news: { caddyfile: SITE, sourceFile: pending },
100
+ olds: { caddyfile: SITE },
101
+ output,
102
+ } as never),
103
+ );
104
+ expect(ok).toEqual({ action: 'update' });
105
+ const bad = withProvider((provider) =>
106
+ handler(
107
+ 'diff',
108
+ provider.diff,
109
+ )({
110
+ ...ids,
111
+ news: { caddyfile: 'SYNTAX_ERROR', sourceFile: pending },
112
+ olds: { caddyfile: SITE },
113
+ output,
114
+ } as never),
115
+ );
116
+ await expect(bad).rejects.toThrow(/Error during parsing/);
117
+ });
118
+ });
119
+
120
+ describe('a Caddy that is down', () => {
121
+ /** A loopback port that just closed: connecting is refused, nothing is listening. */
122
+ const closedPort = () => {
123
+ const server = Bun.serve({ fetch: () => new Response(), hostname: '127.0.0.1', port: 0 });
124
+ const address = `http://127.0.0.1:${String(server.port)}`;
125
+ server.stop(true);
126
+ return address;
127
+ };
128
+ const output = { configSha256: 'x', endpoint: 'y' };
129
+
130
+ test('does not fail the plan: read finds nothing to adopt, diff plans an update', async () => {
131
+ const address = closedPort();
132
+ const probe = await withProvider(
133
+ (provider) =>
134
+ handler('read', provider.read)({ ...ids, olds: { caddyfile: SITE }, output: undefined }),
135
+ address,
136
+ );
137
+ expect(probe).toBeUndefined();
138
+ const diff = await withProvider(
139
+ (provider) =>
140
+ handler(
141
+ 'diff',
142
+ provider.diff,
143
+ )({ ...ids, news: { caddyfile: SITE }, olds: { caddyfile: SITE }, output } as never),
144
+ address,
145
+ );
146
+ expect(diff).toEqual({ action: 'update' });
147
+ });
148
+
149
+ test('but the apply, which needs Caddy, fails loudly', async () => {
150
+ const applying = withProvider(
151
+ (provider) =>
152
+ provider.reconcile({
153
+ ...ids,
154
+ bindings: [] as never,
155
+ news: { caddyfile: SITE },
156
+ olds: undefined,
157
+ output: undefined,
158
+ session: undefined as never,
159
+ }),
160
+ closedPort(),
161
+ );
162
+ await expect(applying).rejects.toThrow(/Caddy admin POST \/adapt at http:\/\/127\.0\.0\.1/);
163
+ });
164
+
165
+ test('a bad Caddyfile still fails the plan when Caddy IS up (only unreachability is forgiven)', async () => {
166
+ const bad = withProvider((provider) =>
167
+ handler(
168
+ 'diff',
169
+ provider.diff,
170
+ )({
171
+ ...ids,
172
+ news: { caddyfile: 'SYNTAX_ERROR' },
173
+ olds: { caddyfile: SITE },
174
+ output,
175
+ } as never),
176
+ );
177
+ await expect(bad).rejects.toThrow(/Error during parsing/);
178
+ });
179
+ });
180
+
181
+ describe('caddyWithFile', () => {
182
+ const stack = () => ({ actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' });
183
+
184
+ test('declares the HostFile (retained) and a CaddyConfig ordered after it', async () => {
185
+ const spec = stack();
186
+ const { config, file } = await Effect.runPromise(
187
+ caddyWithFile('caddy', { caddyfile: SITE, path: '/usr/local/etc/Caddyfile' }).pipe(
188
+ Effect.provideService(Stack, spec as never),
189
+ ) as Effect.Effect<{ config: unknown; file: unknown }>,
190
+ );
191
+ expect(Object.keys(spec.resources).sort()).toEqual(['caddy', 'caddy-file']);
192
+ const target = config as { Props: { sourceFile: unknown }; RemovalPolicy: string };
193
+ // ★ An Output of the HostFile, not the literal path: that edge is what writes the file first.
194
+ expect(Output.isExpr(target.Props.sourceFile)).toBe(true);
195
+ expect(target.RemovalPolicy).toBe('retain');
196
+ expect((file as { RemovalPolicy: string }).RemovalPolicy).toBe('retain');
197
+ });
198
+
199
+ test('a literal secret is refused before the HostFile — and its content prop — exist', async () => {
200
+ const spec = stack();
201
+ const failure = Effect.runPromise(
202
+ Effect.flip(
203
+ caddyWithFile('caddy', {
204
+ caddyfile: 'dns cloudflare abcdef0123456789',
205
+ path: '/etc/Caddyfile',
206
+ }),
207
+ ).pipe(Effect.provideService(Stack, spec as never)) as Effect.Effect<Error>,
208
+ );
209
+ expect((await failure).message).toMatch(/caddyWithFile caddy: line 1/);
210
+ expect(spec.resources).toEqual({});
211
+ });
212
+ });
213
+
214
+ describe('the barrel', () => {
215
+ test('exports the public API and not the internals', () => {
216
+ expect(Object.keys(barrel).sort()).toEqual([
217
+ 'CaddyAdminError',
218
+ 'CaddyAdminService',
219
+ 'CaddyConfig',
220
+ 'CaddyConfigProvider',
221
+ 'CaddyUnreachableError',
222
+ 'DEFAULT_ADMIN_ADDRESS',
223
+ 'caddyAdminLayer',
224
+ 'caddyProviders',
225
+ 'caddyWithFile',
226
+ 'localCaddyAdmin',
227
+ ]);
228
+ });
229
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The Caddy provider with its admin transport, as a single layer for a host stack.
3
+ *
4
+ * Layer.mergeAll(caddyProviders(), launchdProviders(), …the stack's other providers)
5
+ *
6
+ * ★ launchdProviders() TOO when the stack uses caddyWithFile(): the file is a launchd-subpath
7
+ * HostFile, written through that subpath's HostRunner.
8
+ * ⚠️ The default is localCaddyAdmin(): `http://127.0.0.1:2019`, Caddy's default admin listener.
9
+ * Pass another CaddyAdmin for a unix socket, a narrowed `admin { origins }`, or an SSH forward.
10
+ */
11
+ import * as Layer from 'effect/Layer';
12
+ import { type CaddyAdmin, caddyAdminLayer } from './admin.ts';
13
+ import { CaddyConfigProvider } from './config.ts';
14
+ import { localCaddyAdmin } from './local-admin.ts';
15
+
16
+ export const caddyProviders = (admin: CaddyAdmin = localCaddyAdmin()) =>
17
+ CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin)));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `caddyWithFile` — one Caddyfile, applied to the running Caddy AND written to the file Caddy
3
+ * starts from, so a restart keeps it (decision 23: "the file also lands on disk").
4
+ *
5
+ * ★ REUSES launchd's HostFile, NOT A SECOND FILE WRITER. The file is an ordinary `Host.File`
6
+ * (atomic write, digest diff, symlink refusal, `--adopt` for a file already there); the config is
7
+ * a `Caddy.Config` whose `sourceFile` is that HostFile's `path` Output — which is what orders them.
8
+ *
9
+ * ⛔ THE ORDER IS FILE, THEN `/load`. The dependency makes Alchemy write the file first, and it is the
10
+ * only order that bootstraps: a new host's Caddy launchd job starts from the file, before any
11
+ * admin API exists to load into. Its cost, and what bounds it:
12
+ * · A Caddyfile that does not ADAPT never reaches disk once the config has state: CaddyConfig's
13
+ * diff adapts it at PLAN time and fails the plan. (On the very first deploy the file's path is
14
+ * still an Output, Alchemy skips the adoption probe, and the check runs at apply instead.)
15
+ * · A Caddyfile that adapts but that Caddy then REFUSES (a port in use, a missing cert file) is
16
+ * on disk when `/load` fails. Caddy keeps serving the old config, the deploy fails with Caddy's
17
+ * reason — and a Caddy started without `--resume` would load the refused file on its next
18
+ * restart. Managed Caddies run with `--resume` (decision, 2026-09-21; docs/caddy.md): they
19
+ * restart from `autosave.json`, the last config Caddy ACCEPTED, and the file is only the
20
+ * first-boot fallback.
21
+ * · The skipped probe is also why the ADOPTION check of a first deploy happens at apply: a Caddy
22
+ * running a config this stack did not load is refused before `/load` unless the deploy runs
23
+ * with `--adopt` (config-lifecycle.ts reconcileConfig) — after this file is written.
24
+ * ⛔ SECRETS ARE REFUSED HERE, AT DECLARATION, before the HostFile exists: its `content` is a prop
25
+ * too, and would carry a literal into state even if the CaddyConfig then refused it.
26
+ * ★ BOTH RETAIN. Removing this from a stack must not delete the file a restart needs.
27
+ */
28
+ import * as Effect from 'effect/Effect';
29
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
30
+ import { HostFile } from '../launchd/host-file.ts';
31
+ import { CaddyConfig } from './config.ts';
32
+ import { configProblems } from './config-form.ts';
33
+
34
+ export interface CaddyWithFileProps {
35
+ /** The whole Caddyfile. ⛔ Never a secret — use `{env.NAME}` / `{file./path}` placeholders. */
36
+ readonly caddyfile: string;
37
+ /** Absolute path Caddy is started with: `caddy run --config <path> --adapter caddyfile`. */
38
+ readonly path: string;
39
+ /** @default 0o644 */
40
+ readonly mode?: number;
41
+ readonly owner?: string | number;
42
+ readonly group?: string | number;
43
+ }
44
+
45
+ /**
46
+ * Declare the file (`<id>-file`) and the config (`<id>`). Provide `launchdProviders()` for the
47
+ * file and `caddyProviders()` for the config.
48
+ *
49
+ * const { config, file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });
50
+ */
51
+ export const caddyWithFile = (id: string, props: CaddyWithFileProps) =>
52
+ Effect.gen(function* () {
53
+ const found = configProblems({ caddyfile: props.caddyfile, sourceFile: props.path });
54
+ if (found.length > 0) {
55
+ return yield* Effect.fail(new Error(`caddyWithFile ${id}: ${found.join('; ')}`));
56
+ }
57
+ const file = yield* HostFile(`${id}-file`, {
58
+ content: props.caddyfile,
59
+ mode: props.mode ?? 0o644,
60
+ path: props.path,
61
+ ...(props.owner === undefined ? {} : { owner: props.owner }),
62
+ ...(props.group === undefined ? {} : { group: props.group }),
63
+ }).pipe(RemovalPolicy.retain());
64
+ const config = yield* CaddyConfig(id, { caddyfile: props.caddyfile, sourceFile: file.path });
65
+ return { config, file };
66
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A fake Cloudflare `warp_connector` API for the MeshNode tests — an in-memory `fetch` handed to
3
+ * Effect's real FetchHttpClient through its `Fetch` reference, so the tests exercise distilled's
4
+ * real path assembly, query and body encoding, envelope decoding and error matching.
5
+ *
6
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
7
+ * `api.example.com`, and any request outside `/accounts/<FAKE_ACCOUNT>/warp_connector` throws, so
8
+ * a mis-wired test fails instead of reaching a real API. The credentials are placeholders.
9
+ * ★ THE NAME FILTER IS A SUBSTRING MATCH ON PURPOSE. The real filter's exactness is undocumented;
10
+ * a loose fake proves the client re-checks the exact name.
11
+ * ★ PAGES OF TWO by default, so every lookup walks more than one page.
12
+ * ★ `is_deleted` IS HONOURED AS THE SDK DOCUMENTS IT: `false` → live only, `true` → deleted only,
13
+ * absent → every tunnel. So a lookup that forgot the filter sees tombstones and must drop them.
14
+ * ★ `onCreate` runs before a POST is answered: a test uses it to put a node in the way between
15
+ * the provider's lookup and its create — what a retried POST whose first response was lost does.
16
+ */
17
+ import { type Credentials, fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
18
+ import * as Cloudflare from 'alchemy/Cloudflare';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Layer from 'effect/Layer';
21
+ import * as Redacted from 'effect/Redacted';
22
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
23
+
24
+ export const FAKE_BASE = 'https://api.example.com/client/v4';
25
+ export const FAKE_ACCOUNT = '00000000000000000000000000000001';
26
+ /** ⚠️ A placeholder, not a credential. Tests assert it is what the SDK sends. */
27
+ export const FAKE_API_TOKEN = 'placeholder-api-token';
28
+
29
+ export type FakeNode = {
30
+ id: string;
31
+ name: string;
32
+ ha: boolean;
33
+ status: string;
34
+ token: string;
35
+ deleted_at: string | null;
36
+ };
37
+
38
+ export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
39
+
40
+ const ok = (result: unknown, extra: Record<string, unknown> = {}) =>
41
+ Response.json({ success: true, errors: [], messages: [], result, ...extra });
42
+
43
+ export const fakeFailure = (status: number, code: number, message: string) =>
44
+ Response.json(
45
+ { success: false, errors: [{ code, message }], messages: [], result: null },
46
+ { status },
47
+ );
48
+
49
+ /** The wire shape of a node, as the API documents it — plus `token` on create, as the HA page says. */
50
+ const wire = (node: FakeNode) => ({
51
+ id: node.id,
52
+ account_tag: FAKE_ACCOUNT,
53
+ created_at: '2026-09-21T00:00:00Z',
54
+ deleted_at: node.deleted_at,
55
+ name: node.name,
56
+ status: node.status,
57
+ tun_type: 'warp_connector',
58
+ });
59
+
60
+ export type FakeOptions = {
61
+ readonly perPage?: number;
62
+ /** Return a Response to answer the POST with it instead (e.g. `fakeFailure(409, 1013, …)`). */
63
+ readonly onCreate?: (name: string) => Response | void;
64
+ };
65
+
66
+ export const fakeMesh = (options: FakeOptions = {}) => {
67
+ const nodes = new Map<string, FakeNode>();
68
+ const seen: Seen[] = [];
69
+ const auth: string[] = [];
70
+ let next = 1;
71
+ const live = () => [...nodes.values()].filter((node) => node.deleted_at === null);
72
+ const listed = (isDeleted: string | null) =>
73
+ [...nodes.values()].filter((node) =>
74
+ isDeleted === null ? true : (node.deleted_at !== null) === (isDeleted === 'true'),
75
+ );
76
+
77
+ const seed = (node: Partial<FakeNode> & { name: string }): FakeNode => {
78
+ const id = node.id ?? `00000000-0000-4000-8000-${String(next++).padStart(12, '0')}`;
79
+ const full: FakeNode = {
80
+ id,
81
+ ha: false,
82
+ status: 'inactive',
83
+ token: `fake-node-token-${id}`,
84
+ deleted_at: null,
85
+ ...node,
86
+ };
87
+ nodes.set(id, full);
88
+ return full;
89
+ };
90
+
91
+ const route = (method: string, url: URL, body: Record<string, unknown>): Response => {
92
+ const prefix = `/client/v4/accounts/${FAKE_ACCOUNT}/warp_connector`;
93
+ if (url.host !== 'api.example.com' || !url.pathname.startsWith(prefix)) {
94
+ throw new Error(`fake-mesh: unexpected request ${method} ${url.href}`);
95
+ }
96
+ const [id, sub] = url.pathname.slice(prefix.length).split('/').filter(Boolean);
97
+ if (id === undefined && method === 'GET') {
98
+ const filter = url.searchParams.get('name') ?? '';
99
+ const page = Number(url.searchParams.get('page') ?? '1');
100
+ const perPage = Number(url.searchParams.get('per_page') ?? options.perPage ?? 2);
101
+ const hits = listed(url.searchParams.get('is_deleted')).filter((node) =>
102
+ node.name.includes(filter),
103
+ );
104
+ const slice = hits.slice((page - 1) * perPage, page * perPage);
105
+ return ok(slice.map(wire), { result_info: { page, per_page: perPage, count: slice.length } });
106
+ }
107
+ if (id === undefined && method === 'POST') {
108
+ const name = String(body['name']);
109
+ const override = options.onCreate?.(name);
110
+ if (override !== undefined) return override;
111
+ if (live().some((node) => node.name === name)) {
112
+ return fakeFailure(409, 1013, 'Tunnel with name already exists');
113
+ }
114
+ const node = seed({ name, ha: body['ha'] === true });
115
+ return ok({ ...wire(node), token: node.token });
116
+ }
117
+ const node = id === undefined ? undefined : nodes.get(id);
118
+ if (node === undefined) return fakeFailure(404, 1002, 'Tunnel not found');
119
+ if (sub === 'token' && method === 'GET') return ok(node.token);
120
+ if (sub !== undefined) throw new Error(`fake-mesh: unexpected ${method} ${url.pathname}`);
121
+ if (method === 'GET') return ok(wire(node));
122
+ if (method === 'PATCH') {
123
+ const name = String(body['name']);
124
+ if (live().some((other) => other.name === name && other.id !== node.id)) {
125
+ return fakeFailure(409, 1013, 'Tunnel with name already exists');
126
+ }
127
+ node.name = name;
128
+ return ok(wire(node));
129
+ }
130
+ if (method === 'DELETE') {
131
+ if (node.deleted_at !== null) return fakeFailure(404, 1002, 'Tunnel not found');
132
+ node.deleted_at = '2026-09-21T01:00:00Z';
133
+ return ok(wire(node));
134
+ }
135
+ throw new Error(`fake-mesh: unexpected ${method} ${url.pathname}`);
136
+ };
137
+
138
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
139
+ const request =
140
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
141
+ const url = new URL(request.url);
142
+ const text = await request.text();
143
+ const body = text.length === 0 ? {} : (JSON.parse(text) as Record<string, unknown>);
144
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}`, body });
145
+ auth.push(request.headers.get('authorization') ?? '');
146
+ return route(request.method, url, body);
147
+ }) as typeof globalThis.fetch;
148
+
149
+ return { auth, fetch, live, nodes, seed, seen };
150
+ };
151
+
152
+ export type FakeMesh = ReturnType<typeof fakeMesh>;
153
+
154
+ /** distilled Credentials + the real FetchHttpClient over the fake — what a Bun script provides. */
155
+ export const fakeClientLayer = (
156
+ fake: FakeMesh,
157
+ credentials: Layer.Layer<Credentials> = fromApiToken({
158
+ apiToken: FAKE_API_TOKEN,
159
+ apiBaseUrl: FAKE_BASE,
160
+ }),
161
+ ) =>
162
+ Layer.mergeAll(
163
+ credentials,
164
+ FetchHttpClient.layer,
165
+ Layer.succeed(FetchHttpClient.Fetch, fake.fetch),
166
+ );
167
+
168
+ /** …plus the account, as Alchemy's CloudflareEnvironment hands it to a provider. */
169
+ export const fakeProviderLayer = (fake: FakeMesh, accountId: string = FAKE_ACCOUNT) =>
170
+ Layer.mergeAll(
171
+ fakeClientLayer(fake),
172
+ Layer.succeed(
173
+ Cloudflare.CloudflareEnvironment,
174
+ Effect.succeed({
175
+ type: 'apiToken' as const,
176
+ apiToken: Redacted.make(FAKE_API_TOKEN),
177
+ accountId,
178
+ source: { type: 'env' as const },
179
+ }),
180
+ ),
181
+ );
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Cloudflare providers for Alchemy — the gaps the vendor SDK leaves.
3
3
  *
4
- * ★ Built on the official `cloudflare` SDK, not hand-rolled HTTP: it already has
5
- * `r2.buckets.locks.update/get` over the documented endpoints, so there is no path
6
- * string this package invents.
4
+ * ★ Built on vendor SDKs, not hand-rolled HTTP. `R2BucketLock` uses the official `cloudflare`
5
+ * SDK, which already has `r2.buckets.locks.update/get` over the documented endpoints.
6
+ * `MeshNode` uses `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers
7
+ * use, because `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Either way
8
+ * there is no path string this package invents.
7
9
  */
8
10
  export type { R2LockRule } from './lock-rules.ts';
9
11
  export { R2BucketLock, type R2BucketLockProps } from './r2-bucket-lock.ts';
12
+ export { MeshNode, MeshNodeProvider, type MeshNodeServices } from './mesh-node.ts';
13
+ export type { MeshNodeAttributes, MeshNodeProps } from './mesh-node-form.ts';
14
+ export { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';
15
+ export { fetchMeshNodeToken, type MeshNodeRef } from './mesh-node-token.ts';
10
16
  export { providers } from './providers.ts';
11
17
  export {
12
18
  astroWebsite,
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The `warp_connector` calls a Cloudflare Mesh node needs, and nothing else.
3
+ *
4
+ * ★ A MESH NODE IS A `warp_connector` OBJECT. Cloudflare's Mesh docs create one with
5
+ * `POST /accounts/{account_id}/warp_connector` and fetch its enrolment token from
6
+ * `GET …/warp_connector/{id}/token` (developers.cloudflare.com/mesh/get-started, read
7
+ * 2026-09-21). There is no separate "mesh node" endpoint.
8
+ *
9
+ * ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE, not the
10
+ * `cloudflare` npm SDK the R2 lock uses. Measured 2026-09-21: `cloudflare@4.5.0`'s
11
+ * `WARPConnectorCreateParams` has only `account_id` and `name`, so it cannot create an HA node.
12
+ * distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins) has `ha?: boolean` on
13
+ * `CreateTunnelWarpConnectorRequest`, typed errors (`TunnelNotFound` code 1002 / 404,
14
+ * `DuplicateTunnelName` code 1013), and resolves credentials and the account exactly the way
15
+ * `Cloudflare.Tunnel.WarpConnector` and `Cloudflare.Gateway.Rule` do — so the node and the Gateway
16
+ * rules that pin its Mesh IP always land in the same account.
17
+ *
18
+ * ⛔ NO FUNCTION IN THIS FILE READS THE NODE TOKEN, and `observe` copies named fields only. The
19
+ * HA docs say the create response carries a `token` field; distilled's decoder copies only the
20
+ * fields in its schema (protocol.ts, `getProps(outputAst)`), and `observe` below would drop it
21
+ * again if it ever got through. The one token read lives in mesh-node-token.ts, on demand.
22
+ */
23
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
24
+ import * as Data from 'effect/Data';
25
+ import * as Effect from 'effect/Effect';
26
+ import * as Stream from 'effect/Stream';
27
+
28
+ /** `inactive` (never run), `degraded`, `healthy` or `down` — Cloudflare's own four. */
29
+ export type MeshNodeStatus = 'inactive' | 'degraded' | 'healthy' | 'down';
30
+
31
+ /** A live node as this package sees it. ⛔ There is no token field here, on purpose. */
32
+ export interface ObservedNode {
33
+ readonly id: string;
34
+ readonly name: string;
35
+ readonly status: MeshNodeStatus | undefined;
36
+ }
37
+
38
+ /** A refusal or failure, carrying the sentence an operator needs. Never a token. */
39
+ export class MeshNodeError extends Data.TaggedError('MeshNodeError')<{
40
+ readonly message: string;
41
+ }> {}
42
+
43
+ type Raw = {
44
+ readonly id?: string | null;
45
+ readonly name?: string | null;
46
+ readonly status?: string | null;
47
+ readonly deletedAt?: string | null;
48
+ };
49
+
50
+ const STATUSES: readonly string[] = ['inactive', 'degraded', 'healthy', 'down'];
51
+
52
+ /**
53
+ * ⚠️ A DELETED NODE IS NOT A NODE. Cloudflare keeps deleted tunnels (every response carries
54
+ * `deleted_at`, and the list takes `is_deleted`), so a `GET` by id can answer for one. Treating
55
+ * that as live would "adopt" a tombstone and PATCH it.
56
+ */
57
+ export const observe = (raw: Raw): ObservedNode | undefined => {
58
+ if (typeof raw.id !== 'string' || raw.id.length === 0) return undefined;
59
+ if (raw.deletedAt !== undefined && raw.deletedAt !== null) return undefined;
60
+ const status =
61
+ typeof raw.status === 'string' && STATUSES.includes(raw.status)
62
+ ? (raw.status as MeshNodeStatus)
63
+ : undefined;
64
+ return { id: raw.id, name: raw.name ?? '', status };
65
+ };
66
+
67
+ /** The node with this id, or `undefined` when it is gone (404 / code 1002, or soft-deleted). */
68
+ export const getNode = (accountId: string, nodeId: string) =>
69
+ zeroTrust.getTunnelWarpConnector({ accountId, tunnelId: nodeId }).pipe(
70
+ Effect.map(observe),
71
+ Effect.catchTag('TunnelNotFound', () => Effect.succeed(undefined)),
72
+ );
73
+
74
+ /**
75
+ * The live node with exactly this name, or `undefined`.
76
+ *
77
+ * ★ `name` is also sent as the list filter, but the match is re-checked here: the filter's
78
+ * exactness is not documented, and a prefix match would adopt the wrong node.
79
+ * ⛔ Two live matches is refused rather than resolved. Names are unique per account (the create
80
+ * answers code 1013 otherwise), so two means something this package does not understand.
81
+ */
82
+ export const findNodeByName = (accountId: string, name: string) =>
83
+ zeroTrust.listTunnelWarpConnectors.items({ accountId, name, isDeleted: false }).pipe(
84
+ Stream.map(observe),
85
+ Stream.filter((node): node is ObservedNode => node !== undefined && node.name === name),
86
+ Stream.runCollect,
87
+ Effect.flatMap((chunk) => {
88
+ const matches = Array.from(chunk);
89
+ if (matches.length <= 1) return Effect.succeed(matches[0]);
90
+ const ids = matches.map((node) => node.id).join(', ');
91
+ return Effect.fail(
92
+ new MeshNodeError({
93
+ message: `Mesh node name "${name}" matches ${matches.length} live nodes (${ids}). Names are unique per account, so refusing to guess which one is meant.`,
94
+ }),
95
+ );
96
+ }),
97
+ );
98
+
99
+ /**
100
+ * ⛔ `ha` IS SENT ON CREATE AND NOWHERE ELSE. Cloudflare's HA page: "High availability is set at
101
+ * node creation time and cannot be changed afterward." The PATCH body (`PatchTunnelWarpConnector
102
+ * Request`) has only `name` and `tunnelSecret`.
103
+ */
104
+ export const createNode = (accountId: string, name: string, ha: boolean) =>
105
+ zeroTrust.createTunnelWarpConnector({ accountId, name, ha }).pipe(
106
+ Effect.flatMap((raw) => {
107
+ const node = observe(raw);
108
+ return node === undefined
109
+ ? Effect.fail(
110
+ new MeshNodeError({ message: `Creating Mesh node "${name}" returned no id.` }),
111
+ )
112
+ : Effect.succeed(node);
113
+ }),
114
+ );
115
+
116
+ /** ★ A rename is a PATCH: same id, same token, same enrolled replicas and Mesh IP. */
117
+ export const renameNode = (accountId: string, nodeId: string, name: string) =>
118
+ zeroTrust
119
+ .patchTunnelWarpConnector({ accountId, tunnelId: nodeId, name })
120
+ .pipe(Effect.map((raw) => observe(raw) ?? { id: nodeId, name, status: undefined }));
121
+
122
+ /** Idempotent: a node already gone (404 / code 1002) is a successful delete. */
123
+ export const deleteNode = (accountId: string, nodeId: string) =>
124
+ zeroTrust.deleteTunnelWarpConnector({ accountId, tunnelId: nodeId }).pipe(
125
+ Effect.asVoid,
126
+ Effect.catchTag('TunnelNotFound', () => Effect.void),
127
+ );