@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
package/docs/caddy.md ADDED
@@ -0,0 +1,199 @@
1
+ # Caddy — `@homeflare/alchemy/caddy`
2
+
3
+ Declare a running Caddy's config as Caddyfile text and apply it through Caddy's own admin API:
4
+ `POST /load` to apply (a graceful reload), `GET /config/` to detect drift. `caddyWithFile()` also
5
+ writes the same Caddyfile to the file Caddy starts from, with launchd's `HostFile`, so a restart
6
+ keeps it. Every admin call goes through one injectable `CaddyAdmin` transport.
7
+
8
+ ```ts
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+ import { caddyProviders, caddyWithFile } from '@homeflare/alchemy/caddy';
12
+ import { LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
13
+
14
+ const caddyfile = `app.example.com {
15
+ \treverse_proxy 127.0.0.1:8080
16
+ \ttls {
17
+ \t\tdns cloudflare {env.CF_API_TOKEN}
18
+ \t}
19
+ }
20
+ `;
21
+
22
+ export const edge = Effect.gen(function* () {
23
+ const { file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });
24
+ yield* LaunchdJob('caddy-daemon', {
25
+ label: 'com.example.caddy',
26
+ domain: 'system',
27
+ // --resume: a restart runs the last config Caddy accepted (see Managed Caddies below).
28
+ // --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
29
+ programArguments: [
30
+ '/usr/local/bin/caddy',
31
+ 'run',
32
+ '--resume',
33
+ '--config',
34
+ file.path,
35
+ '--adapter',
36
+ 'caddyfile',
37
+ '--envfile',
38
+ '/usr/local/etc/caddy.env',
39
+ ],
40
+ // ⛔ Here, not in the envfile: Caddy fixes its autosave path before it reads --envfile.
41
+ environment: { XDG_CONFIG_HOME: '/usr/local/var/caddy-edge' },
42
+ runAtLoad: true,
43
+ keepAlive: true,
44
+ });
45
+ });
46
+ // ★ launchdProviders() for the HostFile and the job; caddyProviders() for the config.
47
+ export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
48
+ ```
49
+
50
+ ## CaddyConfig
51
+
52
+ | prop | notes |
53
+ | ------------ | ---------------------------------------------------------------------------- |
54
+ | `caddyfile` | the whole Caddyfile; it REPLACES the running config. ⛔ never a secret |
55
+ | `sourceFile` | the file Caddy was started with; `caddyWithFile()` sets it from its HostFile |
56
+
57
+ | attribute | what |
58
+ | -------------- | ------------------------------------------------------------------------------------ |
59
+ | `configSha256` | SHA-256 of the adapted JSON Caddy reported after the apply — never the config itself |
60
+ | `endpoint` | where the admin API was reached |
61
+ | `sourceFile` | as applied |
62
+
63
+ | step | what happens |
64
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
66
+ | apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
67
+ | refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
68
+ | read | the digest of `GET /config/`; with no state, the adoption probe — ⛔ a config that is not the declared one needs `--adopt` ([Adoption](#adoption)) |
69
+ | drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
70
+ | replace | never — a new Caddyfile is a reload of the same Caddy |
71
+ | delete | ⛔ nothing. See [Removing it](#removing-it) |
72
+
73
+ The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
74
+ keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
75
+ against a throwaway Caddy 2.11.4: the digests agree after a load and for a Caddy started from the
76
+ same file, and a refused load kept serving the old config and answered **200** with the error after
77
+ the adapter's warnings, which the provider still reports as a refusal.
78
+
79
+ ⚠️ **A stopped Caddy does not fail the plan.** Its launchd job may be the fix in the same stack, so
80
+ `read` and `diff` plan a create/update with a warning (the plan-time `/adapt` did not run). The apply
81
+ still needs Caddy, and fails loudly until it answers.
82
+
83
+ ## Order: file, then `/load`
84
+
85
+ `caddyWithFile()` passes the HostFile's `path` Output into the CaddyConfig, so Alchemy writes the
86
+ file first. It is the only order that bootstraps: a new host's Caddy job starts from the file,
87
+ before any admin API exists to load into. What bounds the cost:
88
+
89
+ - ★ **A Caddyfile that does not adapt never reaches disk** once the config has state: the plan-time
90
+ `/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
91
+ adoption probe, and the check runs at apply instead.
92
+ - ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
93
+ disk when `/load` fails. Caddy keeps serving the old config, and a managed Caddy's restart runs its
94
+ autosave, not the refused file (below). Without `--resume` a **restart before the fix loads the
95
+ refused file**: fix and redeploy.
96
+ - ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
97
+ - ★ Both resources retain: removing them from a stack leaves the file a restart needs.
98
+
99
+ ## Managed Caddies: `--resume` and their own config dir
100
+
101
+ ★ **Decision, 2026-09-21.** A Caddy this package manages runs `caddy run --resume --config <file>`
102
+ with `XDG_CONFIG_HOME` set to a directory of its own, so a restart runs the last config Caddy
103
+ **accepted**. After every successful load (ours included) Caddy writes the config JSON to
104
+ `$XDG_CONFIG_HOME/caddy/autosave.json`; `--resume` starts from it, and from `--config` only when no
105
+ autosave exists (cmd/commandfuncs.go cmdRun). Read in caddyserver/caddy v2.11.4:
106
+
107
+ - ★ **A refused Caddyfile on disk cannot take sites down.** The file is the first-boot fallback: a
108
+ new host, or an autosave someone deleted.
109
+ - ★ **Its own directory,** so nothing else (a Homebrew service, a person's `caddy run`) writes the
110
+ autosave it resumes from. With neither `HOME` nor `XDG_CONFIG_HOME` a daemon would use `./caddy/`
111
+ under its working directory (storage.go AppConfigDir).
112
+ - ⛔ **Set `XDG_CONFIG_HOME` in the job's `environment`, not the envfile.** The autosave path is
113
+ fixed when the binary starts (storage.go `var ConfigAutosavePath`), before `--envfile` is read.
114
+ - ⛔ **No `persist_config off`** in a managed Caddyfile. It stops the autosave, so `--resume` starts
115
+ from a stale one (the last config before it) or, with none, from the file.
116
+ - ⚠️ **A hand change through the API survives a restart.** The next plan reports it as drift, and the
117
+ deploy puts the declaration back.
118
+ - ⚠️ **SIGUSR1 is not a way to change config.** After a resumed start Caddy records no source file
119
+ (cmdRun calls `SetLastConfig` only after loading `--config`), so SIGUSR1 is ignored with the log
120
+ line `last config unknown`. Our `Caddy-Config-Source-File` header keeps a source Caddy already
121
+ has (caddy.go ClearLastConfigIfDifferent) and never sets one. After a first boot from the file,
122
+ SIGUSR1 still reloads it: the text the deploy wrote. Change config by deploying. Editing the file
123
+ and running `caddy reload` is a hand `/load`, which the next plan reports as drift.
124
+ - ⛔ **No `--watch`.** After a resumed start it has no file to watch, and falls back to a `Caddyfile`
125
+ in the working directory (cmd/main.go watchConfigFile).
126
+
127
+ A Caddy not yet moved over (no `--resume`) restarts from `--config`: see the ⚠️ in
128
+ [Order](#order-file-then-load).
129
+
130
+ ## Secrets
131
+
132
+ Alchemy stores props unencrypted, and the Caddyfile is a prop. Use RUNTIME placeholders, which Caddy
133
+ resolves in its own process, so the value never enters the state, the adapted JSON or the autosave:
134
+
135
+ - `{env.NAME}` — start Caddy with `--envfile <path>`; a secret renderer (openbao-agent) writes that
136
+ file. A rotated value needs a Caddy restart.
137
+ - `{file./path}` — the file's contents (trailing newline trimmed), read when the placeholder runs.
138
+ - ⚠️ `{$NAME}` is substituted at **adapt** time: the value is in the adapted JSON, readable at
139
+ `GET /config/` and written to `autosave.json`. Not refused, but worse.
140
+ - ⛔ `{$NAME:default}` puts the default in the prop, and Caddy uses it whenever `NAME` is unset. The
141
+ tripwire checks the default as a literal.
142
+ - `basic_auth` replaces placeholders in the hash too: `admin {env.ADMIN_HASH}`.
143
+
144
+ The tripwire refuses a PEM private key, a literal after `dns`/`acme_dns <provider>`, a secret-named
145
+ subdirective (`api_token`, `client_secret`, `password`, …) with a literal value, a literal
146
+ `Authorization`/`Cookie`/`X-Api-Key` header, and token shapes (GitHub, AWS, Slack, `sk-…`, JWT,
147
+ bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a scanner.
148
+
149
+ ## The admin endpoint
150
+
151
+ ⛔ The admin API has no authentication: `localCaddyAdmin()` accepts only `http://` loopback or
152
+ `unix:///path` (default `http://127.0.0.1:2019`), and a Caddyfile whose `admin` block would move,
153
+ expose or lock out the API is refused before it loads. Host/Origin checks, narrowed `origins`, SSH
154
+ forwards and every refusal: [caddy-admin.md](./caddy-admin.md).
155
+
156
+ ## Adoption
157
+
158
+ ★ **Nothing is adopted silently** (decision, 2026-09-21) — the rule `HostFile` and `LaunchdJob` keep.
159
+ With no state, `read` is Alchemy's adoption probe, and what the Caddy runs decides:
160
+
161
+ | running | read | plan |
162
+ | ----------------------------------- | --------- | ---------------------------------------------------------- |
163
+ | the declared config (same digest) | ours | adopted; the forced update loads nothing |
164
+ | nothing (`null`, or no apps) | absent | create — nothing to take over, as an empty R2 lock is none |
165
+ | anything else | `Unowned` | refused unless the deploy runs with `--adopt` |
166
+ | a Caddyfile that cannot be compared | `Unowned` | the same, with a warning saying why |
167
+ | no Caddy answering | absent | create, with a warning; the apply checks again |
168
+
169
+ - ⚠️ **Alchemy skips the probe while the props hold an Output** — always on `caddyWithFile()`'s first
170
+ deploy, whose `sourceFile` is the HostFile's path. So the apply of a create checks too, and fails
171
+ before any `/load` unless the deploy runs with `--adopt`. The HostFile is written by then (a file
172
+ already at the path is the HostFile's own `Unowned`); under `--resume` a restart still runs the
173
+ autosave.
174
+ - ★ **That apply-time check resolves adoption as the planner does:** a resource-scoped `adopt(…)`,
175
+ else `--adopt`. So `.pipe(adopt(false))` still refuses under `--adopt`, and `adopt(true)` works.
176
+ - ★ **An uncomparable Caddyfile is `Unowned`, not an error**: the same read recovers an interrupted
177
+ create with that deploy's props, and a throw would fail every later plan, the fixed one included.
178
+
179
+ ## Removing it
180
+
181
+ ⛔ Delete never touches Caddy, under either removal policy. There is no "less config" to fall back
182
+ to, and an empty config stops every server. `retain` is the default; with `destroy`, delete still
183
+ only forgets. It never calls `/stop` or `DELETE /config/`. An empty Caddyfile, or one that adapts
184
+ to no apps (only comments or global options), is refused for the same reason. To empty a Caddy, do
185
+ it by hand at its admin API.
186
+
187
+ ## Traps
188
+
189
+ - ⚠️ `import` paths resolve against Caddy's working directory (the API adapts with no file name).
190
+ Keep them absolute.
191
+ - ⚠️ `/adapt` runs on the **running** binary. A Caddyfile that uses a plugin's directive (Cloudflare
192
+ DNS, caddy-security, layer4) needs that custom build running before it will validate.
193
+ - ⚠️ **Bootstrap race.** Where Caddy is not running yet, the config can reach the admin API before
194
+ its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
195
+ `retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
196
+ - ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
197
+ Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
198
+ applied to, so the apply loads there only if it serves nothing, runs the declared config or the
199
+ one last stored — anything else needs `--adopt`.
@@ -0,0 +1,122 @@
1
+ # Deploying a Mac host as yourself — `sudoRunner()`
2
+
3
+ A host stack that declares system daemons needs root for a handful of calls. `sudoRunner()` lets
4
+ the deploy run as you (the operator) and sends **only those calls** through `sudo -n`, in fixed
5
+ argv shapes, logging each one. It is an explicit opt-in: nothing falls back to it, and
6
+ `localRunner()` stays the default and never elevates.
7
+
8
+ ```ts
9
+ import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
10
+
11
+ const runner = sudoRunner({
12
+ // ⛔ Required: the directories root may write. Each must be a real directory that root owns
13
+ // and only root may write; that is checked at every privileged call.
14
+ prefixes: ['/Library/LaunchDaemons', '/opt/example'],
15
+ // log: (line) => …, // default: one line on stderr per privileged call
16
+ });
17
+ // Provide launchdProviders(runner) alongside the stack's other provider layers.
18
+ ```
19
+
20
+ ## What runs as root: the whole list
21
+
22
+ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never through a shell.
23
+
24
+ | when | argv |
25
+ | -------------------------------- | ---------------------------------------------------------------------- |
26
+ | a system job is bootstrapped | `/bin/launchctl bootstrap system /Library/LaunchDaemons/<label>.plist` |
27
+ | a system job is booted out | `/bin/launchctl bootout system/<label>` |
28
+ | (your own code, via `exec`) | `/bin/launchctl kickstart [-k] [-p] system/<label>` |
29
+ | a file under a prefix is written | `/usr/bin/install -S -m <0644> [-o <uid>] [-g <gid>] <staged> <path>` |
30
+ | a file under a prefix is removed | `/bin/rm -f -- <path>` |
31
+
32
+ - A file's bytes never reach argv or the log: they are written, as you, to a `0600` file in a
33
+ fresh `0700` temp directory. `install` copies that file (and only that file) into place, and
34
+ the temp directory is removed afterwards, whether or not the install succeeded.
35
+ - `install` writes a temp file beside the target and renames it (install(1) on macOS 27.2), so the
36
+ target is never torn. `-S` adds the fsync.
37
+ - Owner and group are numeric ids, resolved by the provider before the call.
38
+
39
+ ## What stays as you
40
+
41
+ - `launchctl print` and `print-disabled`. launchctl(1): "Anyone may read or query the system
42
+ domain"; measured on macOS 27.2, too (see `src/launchd/launchctl.ts`).
43
+ - User and group lookups, `id -G`, every file read and `lstat`.
44
+ - Files outside every prefix, and your own `gui/<uid>` jobs.
45
+
46
+ ★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
47
+
48
+ ## Refused before sudo is asked
49
+
50
+ - Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
51
+ `bootstrap system <directory>` (which loads every plist in it), a plist anywhere but
52
+ `/Library/LaunchDaemons/<label>.plist` (the only place launchd loads daemons from at boot),
53
+ `rm -r`, `install -d`, a label under `org.nixos.`, `com.apple.` or `homebrew.mxcl.`, and any
54
+ argv whose program is `sudo`.
55
+ - A system job's `bootstrap` or `bootout` when `/Library/LaunchDaemons` is not a prefix. ⚠️ Without
56
+ it a delete would boot the job out, then fail to remove its plist, which launchd loads again at
57
+ the next boot.
58
+ - A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
59
+ symlinked prefix makes root write wherever it points, a path the log never names.
60
+ - A path outside every prefix that needs root: another user as the owner, say.
61
+ - A symlink or missing directory between the prefix and the file, or anything but a regular file
62
+ at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
63
+ the same.
64
+ - A file you could not read back. Both providers read every write back, and read again at every
65
+ plan, as you. A root-only `0600` would be installed, then fail with `EACCES` and stay on disk
66
+ with no state. Membership comes from `id -G`, because macOS caps `getgroups()` at 16 (measured
67
+ 2026-09-21: 16 against 18).
68
+ - Another user's `gui/<uid>` or `user/<uid>` domain. Only the system domain is elevated.
69
+
70
+ Every refusal above throws `SudoRefusedError`: nothing ran as root. A privileged command that ran
71
+ and failed throws a plain `Error` with its exit code.
72
+
73
+ ## When sudo wants a password
74
+
75
+ `sudo -n` never prompts. It fails at once, and the error says so: `a password is required, and
76
+ this runner never prompts`. The command did not run. You have two options.
77
+
78
+ 1. **`sudo -v` just before the deploy.** This caches a ticket. By default it lasts 5 minutes and
79
+ is tied to one terminal (sudoers(5): `timestamp_timeout`, `timestamp_type`).
80
+ ⚠️ A deploy that outlives the ticket fails at its next privileged call. Every step is
81
+ idempotent (Alchemy requires `delete` to be), so `sudo -v` and deploying again converges. The
82
+ exception is a `LaunchdJob` label or domain change: it is delete-first, so if it fails between
83
+ the delete and the create, the job is down until you redeploy.
84
+ 2. **A `NOPASSWD` sudoers rule for exactly the shapes above.**
85
+ - ⛔ **That rule is root-equivalent.** Anyone who may `install` into `/Library/LaunchDaemons`
86
+ and `launchctl bootstrap system` can run any program as root. The allowlist protects you
87
+ from mistakes, not from the account that holds the rule.
88
+ - ⛔ **Never use wildcards in the arguments.** sudoers(5): in arguments, `*` also matches spaces
89
+ and `/`, so `/bin/rm -f -- /opt/example/*` matches `../../etc/x` and two paths at once. Use
90
+ `^…$` regular expressions (sudo 1.9.10 and later; macOS 27.2 ships 1.9.17p2) and forbid
91
+ `..`. Check the file with `visudo -c -f <file>` before you install it.
92
+ - ⚠️ The kit ships no tested rule. Nothing here has run sudo.
93
+
94
+ ⚠️ The error text sudo prints is **reasoned, not measured**. It comes from sudoers(5) and sudo(8)
95
+ for sudo 1.9.17p2. A sudo refusal the runner does not recognise comes back as a failed command,
96
+ with sudo's own stderr.
97
+
98
+ ## The log
99
+
100
+ Each privileged call logs one line before it runs:
101
+
102
+ ```text
103
+ homeflare/launchd sudo -n ["/bin/launchctl","bootstrap","system","/Library/LaunchDaemons/com.example.exporter.plist"]
104
+ ```
105
+
106
+ The argv is written as JSON, so a space in a path cannot hide a second argument. The line holds
107
+ the argv only, never file content. ⛔ A custom `log` should keep every line: the log is the only
108
+ record of what ran as root.
109
+
110
+ ## Limits
111
+
112
+ - ⚠️ **Moving a job from `system` to another user's `gui/<uid>` passes the plan and fails at
113
+ apply**, after the old job was already removed. The runner reports `privileged: true`, so the
114
+ plan-time check lets it through, and the refusal only comes at the write. Do that move as a
115
+ remove, then an add.
116
+ - ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
117
+ is the directory's group (a new file's group on macOS).
118
+ - ⚠️ **The checks guard against a mistaken declaration, not against a hostile one.** They run as
119
+ you, just before the call. The prefix itself must be root-only, but a directory _below_ it that
120
+ another user owns is not checked for that, and that user could swap what lies under it in
121
+ between. Keep every directory under a prefix root-owned, too. ACLs are not read, only mode bits.
122
+ - ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.
package/docs/launchd.md CHANGED
@@ -112,8 +112,11 @@ The `system` domain, and another user's `gui/<uid>`, need root. The provider ref
112
112
  unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
113
113
  `privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
114
114
 
115
- - **system daemons:** start the whole deploy as root; or pass `launchdProviders(yourRunner)` where
116
- `yourRunner` is a `HostRunner` you wrote that already holds root (a root helper, say).
115
+ - **system daemons, deployed as yourself:** `launchdProviders(sudoRunner({ prefixes }))` sends only
116
+ the privileged calls through `sudo -n`, from a fixed allowlist, logging each. It never prompts.
117
+ See [launchd-sudo.md](./launchd-sudo.md).
118
+ - **system daemons, deployed as root:** start the whole deploy as root; or pass
119
+ `launchdProviders(yourRunner)` where `yourRunner` is a `HostRunner` that already holds root.
117
120
  - **your own agents** (`gui/<your uid>`): no root needed.
118
121
 
119
122
  Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
@@ -163,5 +166,5 @@ Nix job is booted out first. Per job:
163
166
  `HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
164
167
  `stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
165
168
  `privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
166
- `node:child_process`; tests use an in-memory fake, so nothing in the test suite runs `launchctl` or
167
- writes outside a temp directory.
169
+ `node:child_process`; `sudoRunner()` wraps it and elevates the allowlist. Tests use an in-memory
170
+ fake, so nothing in the test suite runs `launchctl` or `sudo`, or writes outside a temp directory.
@@ -0,0 +1,188 @@
1
+ # MeshNode — a Cloudflare Mesh node without its token in state
2
+
3
+ `MeshNode` (`Cloudflare.MeshNode`) declares a Cloudflare Mesh node: the `warp_connector` object
4
+ that a Linux host joins with the Cloudflare One Client. `fetchMeshNodeToken` fetches the node's
5
+ enrolment token on demand, for a one-off enrolment step. Read 2026-09-21 against
6
+ `alchemy@2.0.0-beta.79`, `@distilled.cloud/cloudflare@1.0.0-rc.12` and Cloudflare's Mesh docs.
7
+
8
+ ## Why not `Cloudflare.Tunnel.WarpConnector`
9
+
10
+ - ⛔ **It puts the node token in state.** Its `toAttributes` calls the token endpoint on every
11
+ `read`, `reconcile` and `list`, and stores the result as an attribute. `Redacted` keeps it out
12
+ of logs, not out of the state store. The token lets any Linux host join the account's Mesh as
13
+ that node.
14
+ - **It has no `ha` prop.** High availability can only be chosen when the node is created.
15
+ - **Its plans need a Write token.** The token endpoint requires "Cloudflare One Connectors Write"
16
+ (or the cloudflared or Tunnel Write groups). `MeshNode` never calls it, so a plan can run on a
17
+ read token.
18
+
19
+ ## Declaring a node
20
+
21
+ ```ts
22
+ import * as Cloudflare from 'alchemy/Cloudflare';
23
+ import { MeshNode, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
24
+
25
+ // providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
26
+ const door = yield * MeshNode('vault-door', { name: 'door-a', ha: false });
27
+ ```
28
+
29
+ | prop | type | notes |
30
+ | ------ | --------- | --------------------------------------------------------------------------------- |
31
+ | `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
32
+ | `ha` | `boolean` | **Required, create-only.** The API default is `false`, the dashboard's is `true`. |
33
+
34
+ Attributes: `id` (the node UUID), `accountId`, `name`, `status` (`inactive`, `degraded`,
35
+ `healthy` or `down`, as of the last read or write) and `ha`. ⛔ There is no token attribute.
36
+
37
+ The account and credentials come from Alchemy's own Cloudflare environment
38
+ (`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
39
+ `CLOUDFLARE_ACCOUNT_ID` in CI. So the node lands in the same account as the `Gateway.Rule` and
40
+ `DNS.Record` resources that refer to it. `providers()` builds that layer itself, so the provider
41
+ works whether or not the stack also merges `Cloudflare.providers()`.
42
+
43
+ ⛔ **Removal policy: `retain` by default**, like the kit's other resources whose deletion breaks
44
+ their consumers. Dropping the declaration or `alchemy destroy` leaves the node live and only drops
45
+ it from state. Opt a declaration into deletion with `.pipe(RemovalPolicy.destroy())`.
46
+
47
+ ### Choosing `ha`: a door is `ha: false`
48
+
49
+ HA fails over the CIDR **routes** a node advertises. A node with no routes (a door: callers dial
50
+ its Mesh IP) gains nothing from it, because the Mesh IP belongs to each replica: "Nodes without
51
+ routes do not benefit from HA failover" (Cloudflare's Mesh HA page, read 2026-09-21). After a
52
+ failover the promoted replica answers on its own Mesh IP, so nothing dialling the first one
53
+ follows it.
54
+
55
+ So a door is `ha: false`, and **a second door is a second `MeshNode`** with its own name, token
56
+ and Mesh IP. Callers list both addresses. Keep `ha: true` for a subnet gateway with routes, on a
57
+ device profile that uses MASQUE.
58
+
59
+ ## What each change does
60
+
61
+ | change | answer | what happens on Cloudflare |
62
+ | ------------------------ | --------------------------- | ------------------------------------------------------------------------------------------- |
63
+ | `name` | `update` | `PATCH`. The id, token, enrolled replicas and Mesh IPs all survive. |
64
+ | `ha`, same `name` | `replace`, **delete-first** | `retain` (default): **refused**, nothing written. `destroy`: old deleted, then new created. |
65
+ | `ha` and `name` together | `replace`, create-first | The new node is created. Under `retain` the old one stays live. |
66
+ | account (provider env) | `replace`, create-first | As above; names are unique per account, so the two cannot collide. |
67
+
68
+ ⛔ **An `ha` replace is an outage for that node.** The new node has a new id and a new token, and
69
+ each replica gets a new Mesh IP. Re-enrol every replica with the new token, and update anything
70
+ that pins the old Mesh IP (Gateway rules, listener allow-lists) in the same change.
71
+
72
+ ★ **Why delete-first only when the name stays.** The new node needs the name the old one holds, so
73
+ creating first would fail with code 1013 (`DuplicateTunnelName`). ⚠️ Unmeasured: whether a
74
+ just-deleted node frees its name at once. If it does not, the create fails with code 1013 and a
75
+ sentence; deploy again once the name is free. An invalid declaration is refused at plan time,
76
+ before anything is deleted.
77
+
78
+ ### An `ha` change under `retain`
79
+
80
+ `retain` also skips the old node's delete inside a replace. So a same-name `ha` change keeps the old
81
+ node, which still holds the name, and the create **refuses** with a sentence and writes nothing. It
82
+ never quietly reuses the old node, because that node still has the old `ha`. Measured through
83
+ Alchemy's own plan/apply in `mesh-node-policy.test.ts`:
84
+
85
+ | you want | do |
86
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------- |
87
+ | the replace | Deploy once with `.pipe(RemovalPolicy.destroy())`: delete, then create. Then drop it. |
88
+ | the replace, by hand | Delete the old node yourself, then deploy again. |
89
+ | to keep the old node | `alchemy state rm <stack>/<stage>/<fqn>` (state only, not the node), then deploy the old declaration + `adopt(true)`. |
90
+
91
+ ⚠️ **Reverting `ha` is not enough.** The refused deploy leaves a `replacing` row, and the next
92
+ plan resumes that replace whatever the props say, so it refuses again.
93
+
94
+ ⚠️ **`adopt(true)` alone does nothing to a `replacing` row.** The plan asks `read` to adopt only
95
+ when a resource has no row, or after an interrupted first create. So every way back from inside a
96
+ replace starts with `alchemy state rm`.
97
+
98
+ ⛔ **The old-node advice holds only when the name stayed.** After an `ha` + `name` change (a
99
+ create-first replace), a node already holding the new name is another node, not the old one.
100
+ `RemovalPolicy.destroy()` never touches it, and the refusal says not to delete it: choose another
101
+ name, or check its HA badge, drop the row and adopt it.
102
+
103
+ ⚠️ Never deploy with `DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response,
104
+ and the create response carries the node's `token` field (Cloudflare's HA page says so).
105
+
106
+ ⚠️ Cloudflare may refuse to delete a node while replicas are connected. This is noted in Alchemy's
107
+ WarpConnector and was not measured here. Stop the client on every replica before a destroy or an
108
+ `ha` replace.
109
+
110
+ ## Adoption
111
+
112
+ An existing node is found by **exact** name and returned `Unowned`. Alchemy refuses to take it
113
+ over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A deleted node is
114
+ never matched, and a name that matches two live nodes is refused.
115
+
116
+ An interrupted create (the node was made, its state was not saved) is found the same way on the
117
+ next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
118
+ 5xx, and the retry is answered code 1013 by the node the first attempt made: the error then names
119
+ that node's id. It is never adopted silently, because a concurrent creator looks the same. Inside
120
+ a replace, drop the row before `adopt(true)`, as above.
121
+
122
+ ⚠️ **Adoption records the declared `ha`.** No documented read returns a node's HA flag:
123
+ `GET /warp_connector/{id}` has no such field. Check the node's **HA** badge in the dashboard
124
+ before adopting. A wrong value cannot be detected by any later plan.
125
+
126
+ ## Enrolling a host: `fetchMeshNodeToken`
127
+
128
+ The token is a runtime credential. It never goes into Alchemy state or the vault; it goes straight
129
+ to the host that runs the node. A one-off Bun `mesh-enroll` step:
130
+
131
+ ```ts
132
+ import { fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
133
+ import { fetchMeshNodeToken } from '@homeflare/alchemy/cloudflare';
134
+ import * as Effect from 'effect/Effect';
135
+ import * as Layer from 'effect/Layer';
136
+ import * as Redacted from 'effect/Redacted';
137
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
138
+ import { open } from 'node:fs/promises';
139
+
140
+ const token = await Effect.runPromise(
141
+ fetchMeshNodeToken({ accountId, id: nodeId }).pipe(
142
+ Effect.provide(Layer.mergeAll(fromApiToken({ apiToken }), FetchHttpClient.layer)),
143
+ ),
144
+ );
145
+ // ⛔ Created 0600 from the start, as root: no window where a chmod has not happened yet.
146
+ const file = await open('/root/mesh-node.token', 'wx', 0o600);
147
+ await file.writeFile(Redacted.value(token));
148
+ await file.close();
149
+ ```
150
+
151
+ - `{ accountId, name }` works too; the name is matched exactly.
152
+ - The value comes back `Redacted`: `console.log`, `Effect.log` and `JSON.stringify` show
153
+ `<redacted>`. Call `Redacted.value` only at the write.
154
+ - ⛔ **`fromApiToken`, not `Credentials.fromEnv()`.** `fromEnv` falls back to the Global API Key
155
+ (`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set.
156
+ - ⚠️ **The endpoint needs a Write permission.** Mint a short-lived token for this step alone. A
157
+ read token gets a 403, and the error names the permission.
158
+ - ⚠️ `warp-cli --accept-tos connector new <token>` (Mesh get-started page) takes the token as an
159
+ **argument**, so it is visible in the host's process list while that command runs. Run it as
160
+ root on the node itself, in a shell that does not keep history.
161
+ - An empty token fails closed. An empty file would otherwise "enrol" a host that never joined.
162
+ - ⛔ **Refused before any request:** `DISTILLED_DEBUG_HTTP` set (distilled then prints the first
163
+ 400 characters of every response body to stderr, which is the whole token), the Global API Key
164
+ (`apiKey` credentials), and an empty API token (a denied secrets grant, not a permission).
165
+
166
+ ## Permissions
167
+
168
+ | step | needs (any one, per the API reference) |
169
+ | ----------------------- | ------------------------------------------------------------------------ |
170
+ | plan / read (get, list) | Cloudflare One Connectors Read, or Cloudflare One Connector: WARP Read |
171
+ | create, rename, delete | Cloudflare One Connectors Write, or Cloudflare One Connector: WARP Write |
172
+ | `fetchMeshNodeToken` | Cloudflare One Connectors Write (see below) |
173
+
174
+ The token endpoint's API reference lists Connectors Write, "Cloudflare One Connector: cloudflared
175
+ Write" and "Cloudflare Tunnel Write"; the Mesh get-started guide lists Connectors Write and
176
+ "Cloudflare One Connector: WARP Write". Connectors Write is the one both name.
177
+
178
+ ## Not covered here
179
+
180
+ - **Routes, device profiles and Gateway rules:** use Alchemy's `Tunnel.Route`,
181
+ `Devices.CustomProfile` and `Gateway.Rule`. ⚠️ HA needs MASQUE on the node's device profile.
182
+ - **The HA provider configuration** (`/warp_connector/{id}/configurations`: `ha_mode` of `none`,
183
+ `disabled`, `aws` or `local` with VIPs) and **manual failover**. Its semantics for a node
184
+ created without `ha` are undocumented, so this resource neither reads nor writes it.
185
+ - **Mesh IPs.** They belong to each replica (the `/connections` endpoint), not to the node, so
186
+ they are not attributes. Pin them in site config and check them against the API.
187
+ - **`alchemy unsafe nuke`.** `list` is empty and nuke skips this type, because Alchemy's own
188
+ WarpConnector provider already lists every `warp_connector` in the account.
package/docs/peers.md ADDED
@@ -0,0 +1,39 @@
1
+ # Peers — and why each pin exists
2
+
3
+ The install line for `@homeflare/alchemy`, and why every peer on it and every entry in the
4
+ `overrides` block is required. The block itself, which every consumer copies, is in the
5
+ [README](../README.md#peers--and-one-override-you-need).
6
+
7
+ ```sh
8
+ bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
9
+ @effect/platform-node@4.0.0-rc.115 cloudflare@4.5.0 mime@4.1.0 \
10
+ @distilled.cloud/cloudflare@1.0.0-rc.12
11
+ ```
12
+
13
+ 🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
14
+ Effect's `rc` line is not semver-compatible with itself. Alchemy 78's peer is
15
+ `effect >= 4.0.0-rc.115`; an unlocked `@effect/platform-node-shared` still floats to the
16
+ next rc and breaks at import. Pin the whole set.
17
+
18
+ | what resolves | what happens |
19
+ | ------------------------------------------------------------- | ------------------------------------------------------ |
20
+ | Alchemy 77 + `effect` → rc.115 (measured 2026-09-16) | `TypeError: Config.string is not a function` at import |
21
+ | `@effect/platform-node-shared` newer than the pinned `effect` | `Cannot find module 'effect/ByteSize'` |
22
+ | `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
23
+ | no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
24
+
25
+ Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
26
+ stay there. The override is still the only thing that holds the set together.
27
+
28
+ ⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
29
+ `Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
30
+ import the Proxmox subpath.
31
+
32
+ ⚠️ So is `mime`. Alchemy 78's `@alchemy.run/cloudflare-runtime` imported it without
33
+ declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
34
+ that followed the 78 README does not drop a required line.
35
+
36
+ ⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
37
+ subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
38
+ `Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
39
+ that an import fails.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.6.0",
4
- "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos and launchd.",
3
+ "version": "0.8.0",
4
+ "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
7
7
  "license": "MIT",
@@ -49,12 +49,17 @@
49
49
  "types": "./dist/launchd/index.d.ts",
50
50
  "default": "./dist/launchd/index.js"
51
51
  },
52
+ "./caddy": {
53
+ "types": "./dist/caddy/index.d.ts",
54
+ "default": "./dist/caddy/index.js"
55
+ },
52
56
  "./package.json": "./package.json"
53
57
  },
54
58
  "publishConfig": {
55
59
  "access": "public"
56
60
  },
57
61
  "peerDependencies": {
62
+ "@distilled.cloud/cloudflare": "1.0.0-rc.12",
58
63
  "@effect/platform-node": "4.0.0-rc.115",
59
64
  "alchemy": "2.0.0-beta.79",
60
65
  "cloudflare": ">=4.5.0",