@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,189 @@
1
+ /**
2
+ * The real CaddyAdmin: this machine's Caddy, over loopback TCP or a unix socket.
3
+ *
4
+ * ★ node:http, NOT fetch. MEASURED 2026-09-21 on bun 1.4.0 and node 26.7.0: node:http sends a Host
5
+ * override and dials a unix socket on BOTH runtimes, while node's fetch silently drops a `Host`
6
+ * header (it sent the URL's host) and only Bun's fetch has a `unix` option. The published dist
7
+ * loads under node too (launchd's local-runner.ts makes the same call for the same reason).
8
+ * ⛔ LOOPBACK OR A UNIX SOCKET, NOTHING ELSE. The admin API is unauthenticated plaintext; an
9
+ * address off this machine means it is reachable from the network, which decision 23 forbids.
10
+ * Reaching another host's Caddy means forwarding its socket or port to here (SSH), never
11
+ * exposing :2019.
12
+ * ★ HOST AND ORIGIN, AS CADDY's OWN CLI SENDS THEM (cmd/commandfuncs.go AdminAPIRequest): over
13
+ * TCP, `Host` and `Origin: http://<host>`; over a socket, `Host: 127.0.0.1` and no Origin, because
14
+ * Caddy skips the Host check on unix listeners (admin.go allowedOrigins). MEASURED against the
15
+ * mini's Caddy 2.11.4: any other Host gets 403 `host not allowed`, a forwarded port included —
16
+ * hence `hostHeader`.
17
+ */
18
+ import { request as httpRequest } from 'node:http';
19
+ import {
20
+ type CaddyAdmin,
21
+ type CaddyAdminListener,
22
+ type CaddyAdminResponse,
23
+ CaddyUnreachableError,
24
+ } from './admin.ts';
25
+
26
+ export type LocalCaddyAdminOptions = {
27
+ /**
28
+ * `http://<loopback>:<port>` or `unix:///absolute/path.sock`.
29
+ * @default 'http://127.0.0.1:2019' — Caddy's default admin listener (`localhost:2019`).
30
+ */
31
+ readonly address?: string;
32
+ /**
33
+ * The Host (and Origin) to send over TCP, when it is not the address's own: the Caddyfile
34
+ * narrowed `admin { origins … }`, or the port is an SSH forward of the real one (Caddy checks
35
+ * the Host against ITS port, not yours). Form `host:port`.
36
+ */
37
+ readonly hostHeader?: string;
38
+ /**
39
+ * Abort an exchange after this long. @default 60_000
40
+ * ⚠️ A `shutdown_delay` in the Caddyfile makes `/load` block that long (caddyhttp app.go Stop);
41
+ * a timeout then fails the deploy while Caddy goes on to apply — the next plan converges.
42
+ */
43
+ readonly timeoutMs?: number;
44
+ /**
45
+ * Retries when the connection is REFUSED (nothing listening yet — a Caddy launchd job that was
46
+ * bootstrapped a moment ago). Only refusals: those never reached Caddy, so re-sending a
47
+ * `POST /load` cannot apply it twice. @default 2
48
+ */
49
+ readonly retries?: number;
50
+ /** @default 500 */
51
+ readonly retryDelayMs?: number;
52
+ };
53
+
54
+ export const DEFAULT_ADMIN_ADDRESS = 'http://127.0.0.1:2019';
55
+
56
+ type Target =
57
+ | { readonly kind: 'tcp'; readonly host: string; readonly port: number }
58
+ | { readonly kind: 'unix'; readonly socketPath: string };
59
+
60
+ const UNIX = 'unix://';
61
+ const HOST_PORT = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}$/;
62
+
63
+ const refuse = (address: string, why: string): Error =>
64
+ new Error(`Caddy admin address ${JSON.stringify(address)}: ${why}`);
65
+
66
+ const isLoopback = (hostname: string): boolean =>
67
+ hostname === 'localhost' || hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(hostname);
68
+
69
+ /** The dial target and listener for an address, or a refusal naming what is wrong with it. */
70
+ export const parseAdminAddress = (
71
+ address: string,
72
+ hostHeader?: string,
73
+ ): { target: Target; listener: CaddyAdminListener } => {
74
+ if (hostHeader !== undefined && !HOST_PORT.test(hostHeader)) {
75
+ throw refuse(address, `hostHeader ${JSON.stringify(hostHeader)} must be host:port`);
76
+ }
77
+ if (address.startsWith(UNIX)) {
78
+ const socketPath = address.slice(UNIX.length);
79
+ if (!socketPath.startsWith('/')) throw refuse(address, 'the socket path must be absolute');
80
+ return { listener: { kind: 'unix', path: socketPath }, target: { kind: 'unix', socketPath } };
81
+ }
82
+ let url: URL;
83
+ try {
84
+ url = new URL(address);
85
+ } catch {
86
+ throw refuse(address, 'not a URL (http://127.0.0.1:2019 or unix:///path)');
87
+ }
88
+ // ⛔ Caddy's local admin endpoint is plaintext by design; its TLS one (`admin.remote`, mTLS on
89
+ // :2021) is a different, network-facing API this transport deliberately does not speak.
90
+ if (url.protocol !== 'http:') throw refuse(address, 'only http:// (loopback) or unix://');
91
+ if (!isLoopback(url.hostname)) {
92
+ throw refuse(address, 'not loopback — the admin API is unauthenticated; forward it to here');
93
+ }
94
+ if (url.pathname !== '/' || url.search !== '' || url.username !== '') {
95
+ throw refuse(address, 'no path, query or credentials — the API lives at the root');
96
+ }
97
+ const port = url.port === '' ? 80 : Number(url.port);
98
+ // ⚠️ NOT `url.host`: WHATWG URL drops a default port (`http://127.0.0.1:80` has host
99
+ // `127.0.0.1`), and Caddy compares the Host against `host:port` (admin.go allowedOrigins), so
100
+ // a portless Host is a 403 on every call. The Caddy CLI always sends the port (JoinHostPort).
101
+ const host = hostHeader ?? `${url.hostname}:${String(port)}`;
102
+ return {
103
+ listener: {
104
+ hostHeader: host,
105
+ kind: 'tcp',
106
+ port: Number(host.slice(host.lastIndexOf(':') + 1)),
107
+ },
108
+ // ★ `hostname` keeps IPv6 brackets; node:http wants them off.
109
+ target: { host: url.hostname.replace(/^\[(.*)\]$/, '$1'), kind: 'tcp', port },
110
+ };
111
+ };
112
+
113
+ const code = (cause: unknown): string | undefined =>
114
+ typeof cause === 'object' && cause !== null && 'code' in cause
115
+ ? String((cause as { code: unknown }).code)
116
+ : undefined;
117
+
118
+ /** Nothing accepted the connection: refused TCP, or no socket file yet. The request never left. */
119
+ const neverConnected = (cause: unknown): boolean =>
120
+ code(cause) === 'ECONNREFUSED' || code(cause) === 'ENOENT';
121
+
122
+ const exchange = (
123
+ target: Target,
124
+ method: string,
125
+ path: string,
126
+ headers: Record<string, string>,
127
+ body: string | undefined,
128
+ timeoutMs: number,
129
+ ): Promise<CaddyAdminResponse> =>
130
+ new Promise((resolve, reject) => {
131
+ const bytes = body === undefined ? undefined : Buffer.from(body, 'utf8');
132
+ const request = httpRequest(
133
+ {
134
+ ...(target.kind === 'tcp'
135
+ ? { host: target.host, port: target.port }
136
+ : { socketPath: target.socketPath }),
137
+ headers: bytes === undefined ? headers : { ...headers, 'Content-Length': bytes.length },
138
+ method,
139
+ path,
140
+ timeout: timeoutMs,
141
+ },
142
+ (response) => {
143
+ const chunks: Buffer[] = [];
144
+ response.on('data', (chunk: Buffer) => chunks.push(chunk));
145
+ response.on('error', reject);
146
+ response.on('end', () =>
147
+ resolve({
148
+ body: Buffer.concat(chunks).toString('utf8'),
149
+ status: response.statusCode ?? 0,
150
+ }),
151
+ );
152
+ },
153
+ );
154
+ request.on('timeout', () => request.destroy(new Error(`no answer in ${String(timeoutMs)} ms`)));
155
+ request.on('error', reject);
156
+ request.end(bytes);
157
+ });
158
+
159
+ export const localCaddyAdmin = (options: LocalCaddyAdminOptions = {}): CaddyAdmin => {
160
+ const address = options.address ?? DEFAULT_ADMIN_ADDRESS;
161
+ const { listener, target } = parseAdminAddress(address, options.hostHeader);
162
+ const timeoutMs = options.timeoutMs ?? 60_000;
163
+ const retries = options.retries ?? 2;
164
+ const delay = options.retryDelayMs ?? 500;
165
+ const fixed: Record<string, string> =
166
+ listener.kind === 'tcp'
167
+ ? { Host: listener.hostHeader, Origin: `http://${listener.hostHeader}` }
168
+ : { Host: '127.0.0.1' };
169
+ return {
170
+ endpoint: address,
171
+ listener,
172
+ request: async ({ body, headers, method, path }) => {
173
+ for (let attempt = 0; ; attempt += 1) {
174
+ try {
175
+ // oxlint-disable-next-line no-await-in-loop -- sequential by design: retry after a wait
176
+ return await exchange(target, method, path, { ...headers, ...fixed }, body, timeoutMs);
177
+ } catch (cause) {
178
+ if (!neverConnected(cause) || attempt >= retries) {
179
+ const reason = cause instanceof Error ? cause.message : String(cause);
180
+ const message = `Caddy admin ${method} ${path} at ${address}: ${reason}`;
181
+ throw neverConnected(cause) ? new CaddyUnreachableError(message) : new Error(message);
182
+ }
183
+ // oxlint-disable-next-line no-await-in-loop -- the wait between attempts
184
+ await new Promise((resolve) => setTimeout(resolve, delay));
185
+ }
186
+ }
187
+ },
188
+ };
189
+ };
@@ -0,0 +1,244 @@
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 { Unowned } from 'alchemy/AdoptPolicy';
7
+ import * as Output from 'alchemy/Output';
8
+ import { Stack } from 'alchemy/Stack';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+ import { caddyAdminLayer } from './admin.ts';
12
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
13
+ import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
14
+ import * as barrel from './index.ts';
15
+ import { localCaddyAdmin } from './local-admin.ts';
16
+ import { caddyWithFile } from './with-file.ts';
17
+
18
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
19
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
20
+
21
+ let fake: FakeCaddy | undefined;
22
+ afterEach(() => {
23
+ fake?.stop();
24
+ fake = undefined;
25
+ });
26
+
27
+ /** A handler the provider must define; a missing one fails the test by name. */
28
+ const handler = <F>(name: string, fn: F | undefined): F => {
29
+ if (fn === undefined) throw new Error(`provider has no ${name} handler`);
30
+ return fn;
31
+ };
32
+
33
+ const withProvider = <A>(
34
+ use: (p: Effect.Success<typeof CaddyConfig.Provider>) => Effect.Effect<A, unknown>,
35
+ address?: string,
36
+ ) => {
37
+ // ★ A Caddy on its default :2019 behind a forward, as in config-lifecycle.test.ts.
38
+ fake =
39
+ address === undefined
40
+ ? fakeCaddy({ listenPort: 2019, running: { apps: { legacy: true } } })
41
+ : undefined;
42
+ const admin = localCaddyAdmin({
43
+ address: address ?? (fake as FakeCaddy).address,
44
+ hostHeader: '127.0.0.1:2019',
45
+ retries: 0,
46
+ });
47
+ return Effect.runPromise(
48
+ Effect.gen(function* () {
49
+ return yield* use(yield* CaddyConfig.Provider);
50
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin))))),
51
+ );
52
+ };
53
+
54
+ describe('CaddyConfigProvider', () => {
55
+ test('read with no state ADOPTS: plain attributes of the live config, never Unowned', async () => {
56
+ const probe = await withProvider((provider) =>
57
+ handler('read', provider.read)({ ...ids, olds: { caddyfile: SITE }, output: undefined }),
58
+ );
59
+ expect(Unowned.is(probe)).toBe(false);
60
+ expect(probe).toMatchObject({ endpoint: fake?.address });
61
+ });
62
+
63
+ test('reconcile applies; delete then sends nothing at all', async () => {
64
+ await withProvider((provider) =>
65
+ Effect.gen(function* () {
66
+ yield* provider.reconcile({
67
+ ...ids,
68
+ bindings: [] as never,
69
+ news: { caddyfile: SITE },
70
+ olds: undefined,
71
+ output: undefined,
72
+ session: undefined as never,
73
+ });
74
+ const before = fake?.seen.length;
75
+ const output = yield* handler(
76
+ 'read',
77
+ provider.read,
78
+ )({
79
+ ...ids,
80
+ olds: { caddyfile: SITE },
81
+ output: undefined,
82
+ });
83
+ yield* provider.delete({ ...ids, olds: { caddyfile: SITE }, output } as never);
84
+ // ★ Only the read above talked to Caddy: delete never unloads, never stops.
85
+ expect(fake?.seen.length).toBe((before ?? 0) + 1);
86
+ }),
87
+ );
88
+ });
89
+
90
+ test('diff defers to the engine with no prior output', async () => {
91
+ const diff = await withProvider((provider) =>
92
+ handler(
93
+ 'diff',
94
+ provider.diff,
95
+ )({
96
+ ...ids,
97
+ news: { caddyfile: SITE },
98
+ olds: { caddyfile: SITE },
99
+ output: undefined,
100
+ } as never),
101
+ );
102
+ expect(diff).toBeUndefined();
103
+ });
104
+
105
+ test('an unresolved sourceFile still validates the Caddyfile at plan time', async () => {
106
+ const pending = Effect.succeed('/usr/local/etc/Caddyfile');
107
+ const output = { configSha256: 'x', endpoint: 'y' };
108
+ const ok = await withProvider((provider) =>
109
+ handler(
110
+ 'diff',
111
+ provider.diff,
112
+ )({
113
+ ...ids,
114
+ news: { caddyfile: SITE, sourceFile: pending },
115
+ olds: { caddyfile: SITE },
116
+ output,
117
+ } as never),
118
+ );
119
+ expect(ok).toEqual({ action: 'update' });
120
+ const bad = withProvider((provider) =>
121
+ handler(
122
+ 'diff',
123
+ provider.diff,
124
+ )({
125
+ ...ids,
126
+ news: { caddyfile: 'SYNTAX_ERROR', sourceFile: pending },
127
+ olds: { caddyfile: SITE },
128
+ output,
129
+ } as never),
130
+ );
131
+ await expect(bad).rejects.toThrow(/Error during parsing/);
132
+ });
133
+ });
134
+
135
+ describe('a Caddy that is down', () => {
136
+ /** A loopback port that just closed: connecting is refused, nothing is listening. */
137
+ const closedPort = () => {
138
+ const server = Bun.serve({ fetch: () => new Response(), hostname: '127.0.0.1', port: 0 });
139
+ const address = `http://127.0.0.1:${String(server.port)}`;
140
+ server.stop(true);
141
+ return address;
142
+ };
143
+ const output = { configSha256: 'x', endpoint: 'y' };
144
+
145
+ test('does not fail the plan: read finds nothing to adopt, diff plans an update', async () => {
146
+ const address = closedPort();
147
+ const probe = await withProvider(
148
+ (provider) =>
149
+ handler('read', provider.read)({ ...ids, olds: { caddyfile: SITE }, output: undefined }),
150
+ address,
151
+ );
152
+ expect(probe).toBeUndefined();
153
+ const diff = await withProvider(
154
+ (provider) =>
155
+ handler(
156
+ 'diff',
157
+ provider.diff,
158
+ )({ ...ids, news: { caddyfile: SITE }, olds: { caddyfile: SITE }, output } as never),
159
+ address,
160
+ );
161
+ expect(diff).toEqual({ action: 'update' });
162
+ });
163
+
164
+ test('but the apply, which needs Caddy, fails loudly', async () => {
165
+ const applying = withProvider(
166
+ (provider) =>
167
+ provider.reconcile({
168
+ ...ids,
169
+ bindings: [] as never,
170
+ news: { caddyfile: SITE },
171
+ olds: undefined,
172
+ output: undefined,
173
+ session: undefined as never,
174
+ }),
175
+ closedPort(),
176
+ );
177
+ await expect(applying).rejects.toThrow(/Caddy admin POST \/adapt at http:\/\/127\.0\.0\.1/);
178
+ });
179
+
180
+ test('a bad Caddyfile still fails the plan when Caddy IS up (only unreachability is forgiven)', async () => {
181
+ const bad = withProvider((provider) =>
182
+ handler(
183
+ 'diff',
184
+ provider.diff,
185
+ )({
186
+ ...ids,
187
+ news: { caddyfile: 'SYNTAX_ERROR' },
188
+ olds: { caddyfile: SITE },
189
+ output,
190
+ } as never),
191
+ );
192
+ await expect(bad).rejects.toThrow(/Error during parsing/);
193
+ });
194
+ });
195
+
196
+ describe('caddyWithFile', () => {
197
+ const stack = () => ({ actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' });
198
+
199
+ test('declares the HostFile (retained) and a CaddyConfig ordered after it', async () => {
200
+ const spec = stack();
201
+ const { config, file } = await Effect.runPromise(
202
+ caddyWithFile('caddy', { caddyfile: SITE, path: '/usr/local/etc/Caddyfile' }).pipe(
203
+ Effect.provideService(Stack, spec as never),
204
+ ) as Effect.Effect<{ config: unknown; file: unknown }>,
205
+ );
206
+ expect(Object.keys(spec.resources).sort()).toEqual(['caddy', 'caddy-file']);
207
+ const target = config as { Props: { sourceFile: unknown }; RemovalPolicy: string };
208
+ // ★ An Output of the HostFile, not the literal path: that edge is what writes the file first.
209
+ expect(Output.isExpr(target.Props.sourceFile)).toBe(true);
210
+ expect(target.RemovalPolicy).toBe('retain');
211
+ expect((file as { RemovalPolicy: string }).RemovalPolicy).toBe('retain');
212
+ });
213
+
214
+ test('a literal secret is refused before the HostFile — and its content prop — exist', async () => {
215
+ const spec = stack();
216
+ const failure = Effect.runPromise(
217
+ Effect.flip(
218
+ caddyWithFile('caddy', {
219
+ caddyfile: 'dns cloudflare abcdef0123456789',
220
+ path: '/etc/Caddyfile',
221
+ }),
222
+ ).pipe(Effect.provideService(Stack, spec as never)) as Effect.Effect<Error>,
223
+ );
224
+ expect((await failure).message).toMatch(/caddyWithFile caddy: line 1/);
225
+ expect(spec.resources).toEqual({});
226
+ });
227
+ });
228
+
229
+ describe('the barrel', () => {
230
+ test('exports the public API and not the internals', () => {
231
+ expect(Object.keys(barrel).sort()).toEqual([
232
+ 'CaddyAdminError',
233
+ 'CaddyAdminService',
234
+ 'CaddyConfig',
235
+ 'CaddyConfigProvider',
236
+ 'CaddyUnreachableError',
237
+ 'DEFAULT_ADMIN_ADDRESS',
238
+ 'caddyAdminLayer',
239
+ 'caddyProviders',
240
+ 'caddyWithFile',
241
+ 'localCaddyAdmin',
242
+ ]);
243
+ });
244
+ });
@@ -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,62 @@
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 RESTART before the fix would load the refused file. Fix and redeploy, or run
18
+ * Caddy with `--resume` (docs/caddy.md): it restarts from `autosave.json`, the last config Caddy
19
+ * ACCEPTED, and the file is only the first-boot fallback.
20
+ * ⛔ SECRETS ARE REFUSED HERE, AT DECLARATION, before the HostFile exists: its `content` is a prop
21
+ * too, and would carry a literal into state even if the CaddyConfig then refused it.
22
+ * ★ BOTH RETAIN. Removing this from a stack must not delete the file a restart needs.
23
+ */
24
+ import * as Effect from 'effect/Effect';
25
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
26
+ import { HostFile } from '../launchd/host-file.ts';
27
+ import { CaddyConfig } from './config.ts';
28
+ import { configProblems } from './config-form.ts';
29
+
30
+ export interface CaddyWithFileProps {
31
+ /** The whole Caddyfile. ⛔ Never a secret — use `{env.NAME}` / `{file./path}` placeholders. */
32
+ readonly caddyfile: string;
33
+ /** Absolute path Caddy is started with: `caddy run --config <path> --adapter caddyfile`. */
34
+ readonly path: string;
35
+ /** @default 0o644 */
36
+ readonly mode?: number;
37
+ readonly owner?: string | number;
38
+ readonly group?: string | number;
39
+ }
40
+
41
+ /**
42
+ * Declare the file (`<id>-file`) and the config (`<id>`). Provide `launchdProviders()` for the
43
+ * file and `caddyProviders()` for the config.
44
+ *
45
+ * const { config, file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });
46
+ */
47
+ export const caddyWithFile = (id: string, props: CaddyWithFileProps) =>
48
+ Effect.gen(function* () {
49
+ const found = configProblems({ caddyfile: props.caddyfile, sourceFile: props.path });
50
+ if (found.length > 0) {
51
+ return yield* Effect.fail(new Error(`caddyWithFile ${id}: ${found.join('; ')}`));
52
+ }
53
+ const file = yield* HostFile(`${id}-file`, {
54
+ content: props.caddyfile,
55
+ mode: props.mode ?? 0o644,
56
+ path: props.path,
57
+ ...(props.owner === undefined ? {} : { owner: props.owner }),
58
+ ...(props.group === undefined ? {} : { group: props.group }),
59
+ }).pipe(RemovalPolicy.retain());
60
+ const config = yield* CaddyConfig(id, { caddyfile: props.caddyfile, sourceFile: file.path });
61
+ return { config, file };
62
+ });