@homeflare/alchemy 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * CaddyConfig's lifecycle against the fake admin API (fake-caddy.ts): apply, a refused config
3
+ * rolled back, drift, and the refusals that must happen before anything is sent.
4
+ */
5
+ import { afterEach, describe, expect, test } from 'bun:test';
6
+ import type { CaddyAdmin } from './admin.ts';
7
+ import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
8
+ import { configDigest } from './digest.ts';
9
+ import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
10
+ import { localCaddyAdmin } from './local-admin.ts';
11
+
12
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
13
+ const OTHER = 'www.example.com respond <hello> & bye';
14
+
15
+ let fake: FakeCaddy | undefined;
16
+ afterEach(() => {
17
+ fake?.stop();
18
+ fake = undefined;
19
+ });
20
+
21
+ /**
22
+ * ★ Addressed as a Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), so a Caddyfile with
23
+ * no `admin` line — the common case — passes the guard. A Caddy reached on any other port needs
24
+ * `admin <address>` declared, or the first load would move it (admin-guard.ts).
25
+ */
26
+ const setup = (running?: unknown) => {
27
+ fake = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
28
+ const admin = localCaddyAdmin({
29
+ address: fake.address,
30
+ hostHeader: '127.0.0.1:2019',
31
+ retries: 0,
32
+ });
33
+ return { admin, caddy: fake };
34
+ };
35
+
36
+ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
37
+
38
+ describe('apply', () => {
39
+ test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
40
+ const { admin, caddy } = setup();
41
+ const applied = await reconcileConfig(admin, { caddyfile: OTHER });
42
+ expect(applied.loaded).toBe(true);
43
+ // ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
44
+ expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
45
+ expect(applied.attributes.endpoint).toBe(caddy.address);
46
+ const [load] = loads(caddy);
47
+ expect(load?.headers.get('content-type')).toBe('text/caddyfile');
48
+ expect(load?.body).toBe(OTHER);
49
+ });
50
+
51
+ test('a Caddy already running this config is not reloaded', async () => {
52
+ const { admin, caddy } = setup();
53
+ await reconcileConfig(admin, { caddyfile: SITE });
54
+ const again = await reconcileConfig(admin, { caddyfile: SITE });
55
+ expect(again.loaded).toBe(false);
56
+ expect(loads(caddy)).toHaveLength(1);
57
+ });
58
+
59
+ test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
60
+ const { admin, caddy } = setup();
61
+ const path = '/usr/local/etc/Caddyfile';
62
+ const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path });
63
+ expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
64
+ expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
65
+ expect(applied.attributes.sourceFile).toBe(path);
66
+ });
67
+
68
+ test('adapter warnings are returned, not refused', async () => {
69
+ const { admin } = setup();
70
+ const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` });
71
+ expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
72
+ });
73
+ });
74
+
75
+ describe('a config Caddy refuses', () => {
76
+ test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
77
+ const { admin, caddy } = setup();
78
+ await reconcileConfig(admin, { caddyfile: SITE });
79
+ const before = caddy.running;
80
+ await expect(reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` })).rejects.toThrow(
81
+ /address already in use.*kept the previous config/,
82
+ );
83
+ expect(caddy.running).toEqual(before);
84
+ });
85
+
86
+ test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
87
+ const { admin, caddy } = setup();
88
+ await reconcileConfig(admin, { caddyfile: SITE });
89
+ await expect(
90
+ reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }),
91
+ ).rejects.toThrow(/→ 200: loading config.*error in a 200 body.*kept the previous/);
92
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
93
+ });
94
+
95
+ test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
96
+ const { admin, caddy } = setup();
97
+ await reconcileConfig(admin, { caddyfile: SITE });
98
+ // ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
99
+ const swallowing: CaddyAdmin = {
100
+ ...admin,
101
+ request: (call) =>
102
+ call.path === '/load' ? Promise.resolve({ body: '', status: 200 }) : admin.request(call),
103
+ };
104
+ await expect(reconcileConfig(swallowing, { caddyfile: OTHER })).rejects.toThrow(
105
+ /after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/,
106
+ );
107
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
108
+ });
109
+
110
+ test('a Caddyfile that does not adapt is refused before any load', async () => {
111
+ const { admin, caddy } = setup();
112
+ await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' })).rejects.toThrow(
113
+ /\/adapt → 400: Caddyfile:1 - Error during parsing/,
114
+ );
115
+ expect(loads(caddy)).toHaveLength(0);
116
+ });
117
+ });
118
+
119
+ describe('refused before anything is sent', () => {
120
+ test.each([
121
+ ['a literal token', 'example.com {\n dns cloudflare abcdef0123456789\n}'],
122
+ ['an empty Caddyfile', ' \n'],
123
+ ])('%s', async (_, caddyfile) => {
124
+ const { admin, caddy } = setup();
125
+ await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/Caddy\.Config at http/);
126
+ expect(caddy.seen).toHaveLength(0);
127
+ });
128
+
129
+ test.each([
130
+ ['only a comment', '# the template rendered nothing'],
131
+ ['only global options', 'admin localhost:2019'],
132
+ ])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
133
+ const { admin, caddy } = setup();
134
+ await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/adapts to no apps/);
135
+ expect(loads(caddy)).toHaveLength(0);
136
+ });
137
+
138
+ test('a Caddyfile that would turn the admin API off is never loaded', async () => {
139
+ const { admin, caddy } = setup();
140
+ await expect(reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` })).rejects.toThrow(
141
+ /admin off/,
142
+ );
143
+ expect(loads(caddy)).toHaveLength(0);
144
+ });
145
+ });
146
+
147
+ describe('drift', () => {
148
+ const applied = async () => {
149
+ const { admin, caddy } = setup();
150
+ const { attributes } = await reconcileConfig(admin, { caddyfile: SITE });
151
+ return { admin, attributes, caddy };
152
+ };
153
+
154
+ test('converged: declared, live and stored agree → noop', async () => {
155
+ const { admin, attributes } = await applied();
156
+ expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'noop' });
157
+ });
158
+
159
+ test('a changed declaration → update', async () => {
160
+ const { admin, attributes } = await applied();
161
+ expect(await diffConfig(admin, { caddyfile: OTHER }, attributes)).toEqual({ action: 'update' });
162
+ });
163
+
164
+ test('a hand edit through the API → update, and the apply puts the declaration back', async () => {
165
+ const { admin, attributes, caddy } = await applied();
166
+ caddy.running = { apps: { http: { servers: {} } } };
167
+ expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
168
+ const again = await reconcileConfig(admin, { caddyfile: SITE });
169
+ expect(again.attributes.configSha256).toBe(attributes.configSha256);
170
+ });
171
+
172
+ test('a restart with a different file → update', async () => {
173
+ const { admin, attributes, caddy } = await applied();
174
+ caddy.running = JSON.parse(JSON.stringify(caddy.adapt(OTHER))) as unknown;
175
+ expect((await readLive(admin)).configSha256).not.toBe(attributes.configSha256);
176
+ expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
177
+ });
178
+
179
+ test('state that lags the live config → update, so the stored digest is refreshed', async () => {
180
+ const { admin, attributes } = await applied();
181
+ const stale = { ...attributes, configSha256: configDigest({ apps: { before: true } }) };
182
+ expect(await diffConfig(admin, { caddyfile: SITE }, stale)).toEqual({ action: 'update' });
183
+ });
184
+
185
+ test('the transport now reaches Caddy at another endpoint → update (load there)', async () => {
186
+ const { admin, attributes } = await applied();
187
+ const moved = { ...attributes, endpoint: 'unix:///run/caddy/admin.sock' };
188
+ expect(await diffConfig(admin, { caddyfile: SITE }, moved)).toEqual({ action: 'update' });
189
+ });
190
+
191
+ test('a new sourceFile → update, so state records the file this config mirrors', async () => {
192
+ const { admin, attributes } = await applied();
193
+ const diff = await diffConfig(
194
+ admin,
195
+ { caddyfile: SITE, sourceFile: '/etc/Caddyfile' },
196
+ attributes,
197
+ );
198
+ expect(diff).toEqual({ action: 'update' });
199
+ });
200
+ });
201
+
202
+ describe('adopt', () => {
203
+ test('a running Caddy reads as its live config; the first apply replaces it', async () => {
204
+ const { admin, caddy } = setup({
205
+ apps: { http: { servers: { legacy: { listen: [':80'] } } } },
206
+ });
207
+ const adopted = await readLive(admin);
208
+ expect(adopted.configSha256).toBe(configDigest(caddy.running));
209
+ expect(await diffConfig(admin, { caddyfile: SITE }, adopted)).toEqual({ action: 'update' });
210
+ await reconcileConfig(admin, { caddyfile: SITE });
211
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
212
+ });
213
+
214
+ test('an empty Caddy (config null) is adoptable too', async () => {
215
+ const { admin } = setup();
216
+ expect((await readLive(admin)).configSha256).toBe(configDigest(null));
217
+ });
218
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * CaddyConfig's read / diff / reconcile as plain async functions over a CaddyAdmin, so the
3
+ * lifecycle runs against a fake admin server in tests.
4
+ *
5
+ * The comparison is always between DIGESTS OF ADAPTED JSON (digest.ts): what `POST /adapt` makes
6
+ * of the declared Caddyfile, what `GET /config/` reports, and what was stored after the last
7
+ * apply. Three digests, because they answer different questions:
8
+ * · declared ≠ stored — the declaration changed.
9
+ * · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
10
+ * (or an `autosave.json` holding someone else's change, under `--resume`).
11
+ * · declared = live — nothing to load, whatever the state says.
12
+ */
13
+ import type { Diff } from 'alchemy/Diff';
14
+ import type { CaddyAdmin } from './admin.ts';
15
+ import {
16
+ CaddyAdminError,
17
+ adaptCaddyfile,
18
+ loadCaddyfile,
19
+ readRunningConfig,
20
+ } from './admin-calls.ts';
21
+ import { adminProblems } from './admin-guard.ts';
22
+ import {
23
+ type CaddyConfigAttributes,
24
+ type CaddyConfigProps,
25
+ configProblems,
26
+ } from './config-form.ts';
27
+ import { configDigest } from './digest.ts';
28
+
29
+ const refuse = (admin: CaddyAdmin, message: string): Error =>
30
+ new Error(`Caddy.Config at ${admin.endpoint}: ${message}`);
31
+
32
+ const short = (digest: string): string => digest.slice(0, 12);
33
+
34
+ type Desired = { readonly digest: string; readonly warnings: readonly string[] };
35
+
36
+ /**
37
+ * ⛔ A CONFIG WITH NO APPS SERVES NOTHING. A Caddyfile of only comments or only global options
38
+ * passes the empty-text check (config-form.ts) yet adapts to `{}` or `{"admin":…}` — MEASURED
39
+ * 2026-09-21 on a throwaway Caddy 2.11.4 — and loading it stops every server: the same outage,
40
+ * from the same templating bug, that the empty check exists to prevent.
41
+ */
42
+ const servesNothing = (config: unknown): boolean => {
43
+ const apps =
44
+ typeof config === 'object' && config !== null ? (config as { apps?: unknown }).apps : undefined;
45
+ return typeof apps !== 'object' || apps === null || Object.keys(apps).length === 0;
46
+ };
47
+
48
+ /**
49
+ * Validate the props, adapt the Caddyfile on the running Caddy, and check its `admin` block.
50
+ * ★ Every refusal happens HERE, before anything is loaded — and diff calls this at plan time, so a
51
+ * Caddyfile that does not adapt, carries a literal secret, or would strand the admin API fails the
52
+ * PLAN, before any resource (a HostFile holding the same text included) is applied.
53
+ */
54
+ export const desiredConfig = async (
55
+ admin: CaddyAdmin,
56
+ props: CaddyConfigProps,
57
+ ): Promise<Desired> => {
58
+ const found = configProblems(props);
59
+ if (found.length > 0) throw refuse(admin, found.join('; '));
60
+ const adapted = await adaptCaddyfile(admin, props.caddyfile);
61
+ if (servesNothing(adapted.config)) {
62
+ throw refuse(admin, 'the Caddyfile adapts to no apps — loading it would stop every site');
63
+ }
64
+ const guard = adminProblems(adapted.config, admin.listener);
65
+ if (guard.length > 0) throw refuse(admin, guard.join('; '));
66
+ return { digest: configDigest(adapted.config), warnings: adapted.warnings };
67
+ };
68
+
69
+ /** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
70
+ export const readLive = async (
71
+ admin: CaddyAdmin,
72
+ sourceFile?: string,
73
+ ): Promise<CaddyConfigAttributes> => ({
74
+ configSha256: configDigest(await readRunningConfig(admin)),
75
+ endpoint: admin.endpoint,
76
+ ...(sourceFile === undefined ? {} : { sourceFile }),
77
+ });
78
+
79
+ /**
80
+ * ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
81
+ * the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
82
+ * the ⚠️ on `endpoint` in the diff.)
83
+ */
84
+ export const diffConfig = async (
85
+ admin: CaddyAdmin,
86
+ news: CaddyConfigProps,
87
+ output: CaddyConfigAttributes,
88
+ ): Promise<Diff> => {
89
+ const want = await desiredConfig(admin, news);
90
+ const live = await readLive(admin);
91
+ const converged =
92
+ want.digest === live.configSha256 &&
93
+ live.configSha256 === output.configSha256 &&
94
+ news.sourceFile === output.sourceFile &&
95
+ // ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
96
+ // old one keeps what it has — the same as a delete, which never unloads (see config.ts).
97
+ admin.endpoint === output.endpoint;
98
+ return converged ? { action: 'noop' } : { action: 'update' };
99
+ };
100
+
101
+ export type Applied = {
102
+ readonly attributes: CaddyConfigAttributes;
103
+ /** The adapter's warnings (e.g. "Caddyfile input is not formatted") — log them, never refuse. */
104
+ readonly warnings: readonly string[];
105
+ /** Whether a `POST /load` was sent (false when Caddy already ran this config). */
106
+ readonly loaded: boolean;
107
+ };
108
+
109
+ /** Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches. */
110
+ export const reconcileConfig = async (
111
+ admin: CaddyAdmin,
112
+ props: CaddyConfigProps,
113
+ ): Promise<Applied> => {
114
+ const want = await desiredConfig(admin, props);
115
+ const before = configDigest(await readRunningConfig(admin));
116
+ let warnings = want.warnings;
117
+ const loaded = before !== want.digest;
118
+ if (loaded) {
119
+ try {
120
+ warnings = await loadCaddyfile(admin, props.caddyfile, props.sourceFile);
121
+ } catch (cause) {
122
+ if (!(cause instanceof CaddyAdminError)) throw cause;
123
+ // ★ SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go
124
+ // changeConfig); confirm it rather than promise it, because the deploy log is where the
125
+ // person decides whether sites are down.
126
+ const after = await readLive(admin).catch(() => undefined);
127
+ const state =
128
+ after === undefined
129
+ ? 'and the running config could not be read back'
130
+ : after.configSha256 === before
131
+ ? 'Caddy kept the previous config (still serving)'
132
+ : `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;
133
+ throw refuse(admin, `Caddy refused the Caddyfile — ${cause.message}; ${state}`);
134
+ }
135
+ }
136
+ // ⚠️ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but
137
+ // did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is
138
+ // already wrong.
139
+ const attributes = await readLive(admin, props.sourceFile);
140
+ if (attributes.configSha256 !== want.digest) {
141
+ throw refuse(
142
+ admin,
143
+ `after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +
144
+ `${short(want.digest)} this Caddyfile adapts to — another writer changed it, or it was not applied`,
145
+ );
146
+ }
147
+ return { attributes, loaded, warnings };
148
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `Caddy.Config` — a running Caddy's whole config, declared as Caddyfile text and applied through
3
+ * Caddy's own admin API (decision 23).
4
+ *
5
+ * - create / update — `POST /adapt` (validate), `POST /load` with `text/caddyfile` (a graceful
6
+ * reload; Caddy keeps the old config if it refuses the new one, and the error names why), then
7
+ * `GET /config/` and insist it matches. Skipped when Caddy already runs this config.
8
+ * - read — the digest of `GET /config/`. ★ WITH NO STATE, A RUNNING CADDY IS ADOPTED: its live
9
+ * config becomes the starting point, and the deploy's forced update loads the declared one.
10
+ * - diff — declared, live and stored digests (config-lifecycle.ts). Never `replace`.
11
+ * - delete — ⛔ NOTHING. See below.
12
+ *
13
+ * ⛔ DELETE NEVER TOUCHES CADDY, UNDER EITHER REMOVAL POLICY. Removing this declaration must not take
14
+ * every site down: there is no "less config" to fall back to, and an empty config stops all
15
+ * servers. `retain` is the default (the engine never calls delete); with `destroy` the handler
16
+ * still only forgets. It never calls `/stop` (which exits the process) or `DELETE /config/`.
17
+ * To empty a Caddy, do it by hand at its admin API.
18
+ * ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
19
+ * ⚠️ WHICH CADDY is the transport's (providers.ts), not a prop: one CaddyAdmin per stack.
20
+ */
21
+ import { Resource } from 'alchemy';
22
+ import { isResolved } from 'alchemy/Diff';
23
+ import * as Provider from 'alchemy/Provider';
24
+ import * as Effect from 'effect/Effect';
25
+ import { lift, resolvedString } from '../launchd/host-effect.ts';
26
+ import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
27
+ import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
28
+ import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
29
+
30
+ export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
31
+
32
+ export interface CaddyConfig extends Resource<
33
+ 'Caddy.Config',
34
+ CaddyConfigProps,
35
+ CaddyConfigAttributes
36
+ > {}
37
+
38
+ export const CaddyConfig = Resource<CaddyConfig>('Caddy.Config', {
39
+ defaultRemovalPolicy: 'retain',
40
+ });
41
+
42
+ const unreachable = (error: Error): error is CaddyUnreachableError =>
43
+ error instanceof CaddyUnreachableError;
44
+
45
+ /**
46
+ * ⚠️ A STOPPED CADDY MUST NOT FAIL THE PLAN THAT BRINGS IT BACK. Its launchd job usually sits in the
47
+ * same stack; if every plan died on `ECONNREFUSED`, the fix to that job could never be deployed.
48
+ * So at PLAN time only (read, diff) an unreachable Caddy plans an update — with a warning, because
49
+ * the plan-time `/adapt` check did not run — and reconcile, which needs Caddy, still fails loudly.
50
+ */
51
+ const planWithoutCaddy =
52
+ <A>(admin: { endpoint: string }, fallback: A) =>
53
+ (error: Error) =>
54
+ Effect.as(
55
+ Effect.logWarning(
56
+ `Caddy.Config: no Caddy at ${admin.endpoint} (${error.message}); planning without it — ` +
57
+ 'the Caddyfile is validated when the deploy reaches Caddy',
58
+ ),
59
+ fallback,
60
+ );
61
+
62
+ export const CaddyConfigProvider = () =>
63
+ Provider.effect(
64
+ CaddyConfig,
65
+ Effect.gen(function* () {
66
+ const admin = yield* CaddyAdminService;
67
+ return CaddyConfig.Provider.of({
68
+ /** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
69
+ list: () => Effect.succeed([]),
70
+
71
+ /**
72
+ * ★ Plain attributes, never `Unowned`: a Caddy on this host's loopback admin API is the one
73
+ * the stack means (the transport refuses anything else), and adopting reads — it changes
74
+ * nothing until reconcile loads the declared Caddyfile.
75
+ */
76
+ read: ({ olds }) =>
77
+ lift(() => readLive(admin, resolvedString(olds, 'sourceFile'))).pipe(
78
+ // ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
79
+ Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
80
+ ),
81
+
82
+ diff: ({ news, output }) => {
83
+ if (output === undefined) return Effect.succeed(undefined);
84
+ const update = planWithoutCaddy(admin, { action: 'update' as const });
85
+ if (isResolved(news)) {
86
+ return lift(() => diffConfig(admin, news, output)).pipe(
87
+ Effect.catchIf(unreachable, update),
88
+ );
89
+ }
90
+ /**
91
+ * ⚠️ `sourceFile` is an Output while its HostFile changes in the same deploy
92
+ * (caddyWithFile). The Caddyfile itself is usually plain text, so validate and compare
93
+ * it now — a bad Caddyfile must fail the PLAN, before the HostFile writes it to disk —
94
+ * and plan an update, which reloads only if the config really differs.
95
+ */
96
+ const caddyfile = resolvedString(news, 'caddyfile');
97
+ if (caddyfile === undefined) return Effect.succeed(undefined);
98
+ return lift(async () => {
99
+ await diffConfig(admin, { caddyfile }, output);
100
+ return { action: 'update' as const };
101
+ }).pipe(Effect.catchIf(unreachable, update));
102
+ },
103
+
104
+ reconcile: ({ news }) =>
105
+ Effect.gen(function* () {
106
+ const applied = yield* lift(() => reconcileConfig(admin, news));
107
+ for (const warning of applied.warnings) {
108
+ yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
109
+ }
110
+ return applied.attributes;
111
+ }),
112
+
113
+ delete: () => Effect.void,
114
+ });
115
+ }),
116
+ );
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one form an adapted Caddy config is hashed in — so what `POST /adapt` returns for the
3
+ * declared Caddyfile and what `GET /config/` reports after the load hash the same.
4
+ *
5
+ * ⛔ THE TWO ARE NOT THE SAME BYTES, AND COMPARING BYTES WOULD BE A FOREVER-`update`. Read in
6
+ * caddyserver/caddy v2.11.4:
7
+ * · `/adapt` returns the adapter's `json.Marshal(cfg)` of Go STRUCTS — keys in field order
8
+ * (caddyconfig/caddyfile/adapter.go Adapt; load.go handleAdapt).
9
+ * · `/load` decodes that JSON into a generic `any` and re-marshals it (caddy.go changeConfig):
10
+ * Go sorts MAP keys, so the stored config has every object's keys sorted.
11
+ * · `GET /config/` streams that stored value through `json.Encoder`, which appends a newline
12
+ * (admin.go unsyncedConfigAccess), and escapes `<`, `>` and `&` as `\u003c`, `\u003e`, `\u0026`.
13
+ * Parsing both and re-serialising with sorted keys erases all three differences. MEASURED
14
+ * 2026-09-21 on a throwaway Caddy 2.11.4: the digest of `GET /config/` after `POST /load` equals
15
+ * the digest of `/adapt` for the same Caddyfile — and a Caddy started with `--config <file>`
16
+ * hashes the same as `/adapt` of that file's text, so adopting it is a noop.
17
+ * ★ NUMBERS AGREE BY CONSTRUCTION. Go decodes every JSON number in that generic `any` as float64
18
+ * (caddy.go indexConfigObjects says so), and JSON.parse does too — the same double on both sides
19
+ * serialises the same way here.
20
+ */
21
+ import { sha256Hex } from '../launchd/job-form.ts';
22
+
23
+ type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json };
24
+
25
+ const sorted = (value: Json): Json => {
26
+ if (Array.isArray(value)) return (value as readonly Json[]).map(sorted);
27
+ if (typeof value === 'object' && value !== null) {
28
+ const object = value as { readonly [key: string]: Json };
29
+ const out: Record<string, Json> = {};
30
+ for (const key of Object.keys(object).sort()) out[key] = sorted(object[key] ?? null);
31
+ return out;
32
+ }
33
+ return value;
34
+ };
35
+
36
+ /** Parse a config body Caddy returned. ⚠️ An empty config is the JSON `null`, not an error. */
37
+ export const parseConfig = (text: string): unknown => JSON.parse(text) as unknown;
38
+
39
+ /** The canonical text of a parsed config: sorted keys, no whitespace. */
40
+ export const canonicalConfig = (config: unknown): string =>
41
+ JSON.stringify(sorted((config ?? null) as Json));
42
+
43
+ /** SHA-256 of the canonical form — what the resource stores and compares, never the config. */
44
+ export const configDigest = (config: unknown): string => sha256Hex(canonicalConfig(config));