@homeflare/alchemy 0.27.4 → 0.29.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 (217) hide show
  1. package/README.md +2 -0
  2. package/dist/caddy/admin-address.d.ts +27 -0
  3. package/dist/caddy/admin-address.d.ts.map +1 -0
  4. package/dist/caddy/admin-calls.d.ts +25 -28
  5. package/dist/caddy/admin-calls.d.ts.map +1 -1
  6. package/dist/caddy/admin.d.ts +28 -38
  7. package/dist/caddy/admin.d.ts.map +1 -1
  8. package/dist/caddy/caddy-http-client.d.ts +25 -0
  9. package/dist/caddy/caddy-http-client.d.ts.map +1 -0
  10. package/dist/caddy/config-errors.d.ts +24 -0
  11. package/dist/caddy/config-errors.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +20 -49
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  14. package/dist/caddy/config-reconcile.d.ts +45 -0
  15. package/dist/caddy/config-reconcile.d.ts.map +1 -0
  16. package/dist/caddy/config.d.ts +4 -3
  17. package/dist/caddy/config.d.ts.map +1 -1
  18. package/dist/caddy/fake-caddy.d.ts +10 -2
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  20. package/dist/caddy/index.d.ts +3 -3
  21. package/dist/caddy/index.d.ts.map +1 -1
  22. package/dist/caddy/index.js +487 -444
  23. package/dist/caddy/index.js.map +16 -12
  24. package/dist/caddy/local-admin.d.ts +20 -34
  25. package/dist/caddy/local-admin.d.ts.map +1 -1
  26. package/dist/caddy/providers.d.ts +19 -3
  27. package/dist/caddy/providers.d.ts.map +1 -1
  28. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  29. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  30. package/dist/forgejo/branch-protection.d.ts +4 -4
  31. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  32. package/dist/forgejo/org-label.d.ts +4 -4
  33. package/dist/forgejo/org-team.d.ts +4 -4
  34. package/dist/forgejo/repo-webhook.d.ts +4 -4
  35. package/dist/forgejo/repository.d.ts +4 -4
  36. package/dist/forgejo/team-member.d.ts +4 -4
  37. package/dist/github/declare-repo-baseline.d.ts +1 -1
  38. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  39. package/dist/github/index.d.ts +9 -5
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +161 -25
  42. package/dist/github/index.js.map +15 -13
  43. package/dist/github/repo-baseline-data.d.ts +10 -1
  44. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  45. package/dist/github/repo-policy.d.ts +1 -1
  46. package/dist/github/repo-policy.d.ts.map +1 -1
  47. package/dist/github/repository-ruleset-errors.d.ts +37 -3
  48. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  49. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  50. package/dist/github/repository-ruleset-guards.d.ts +15 -2
  51. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  52. package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
  53. package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
  54. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  55. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  57. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-props.d.ts +91 -0
  59. package/dist/github/repository-ruleset-props.d.ts.map +1 -0
  60. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  61. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  62. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
  63. package/dist/github/repository-ruleset.d.ts +28 -34
  64. package/dist/github/repository-ruleset.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts +27 -16
  66. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  67. package/dist/litellm/fake-stack.d.ts +9 -4
  68. package/dist/litellm/fake-stack.d.ts.map +1 -1
  69. package/dist/litellm/index.d.ts +7 -5
  70. package/dist/litellm/index.d.ts.map +1 -1
  71. package/dist/litellm/index.js +53 -123
  72. package/dist/litellm/index.js.map +8 -8
  73. package/dist/litellm/operations.d.ts +60 -0
  74. package/dist/litellm/operations.d.ts.map +1 -0
  75. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  76. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  77. package/dist/litellm/pass-through-errors.d.ts +37 -0
  78. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  79. package/dist/litellm/pass-through-form.d.ts +11 -4
  80. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  81. package/dist/litellm/providers.d.ts +7 -5
  82. package/dist/litellm/providers.d.ts.map +1 -1
  83. package/dist/netbox/prefix.d.ts +4 -4
  84. package/dist/paperless/custom-field.d.ts +21 -21
  85. package/dist/paperless/custom-field.d.ts.map +1 -1
  86. package/dist/paperless/document-type.d.ts +34 -4
  87. package/dist/paperless/document-type.d.ts.map +1 -1
  88. package/dist/paperless/fake-paperless.d.ts +36 -19
  89. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  90. package/dist/paperless/index.d.ts +7 -2
  91. package/dist/paperless/index.d.ts.map +1 -1
  92. package/dist/paperless/index.js +164 -228
  93. package/dist/paperless/index.js.map +10 -13
  94. package/dist/paperless/matching-locate.d.ts +42 -20
  95. package/dist/paperless/matching-locate.d.ts.map +1 -1
  96. package/dist/paperless/matching-types.d.ts +47 -18
  97. package/dist/paperless/matching-types.d.ts.map +1 -1
  98. package/dist/paperless/matching.d.ts +30 -27
  99. package/dist/paperless/matching.d.ts.map +1 -1
  100. package/dist/paperless/providers.d.ts +13 -7
  101. package/dist/paperless/providers.d.ts.map +1 -1
  102. package/dist/paperless/storage-path.d.ts +34 -4
  103. package/dist/paperless/storage-path.d.ts.map +1 -1
  104. package/dist/paperless/tag.d.ts +43 -8
  105. package/dist/paperless/tag.d.ts.map +1 -1
  106. package/dist/proxmox/acl.d.ts +30 -23
  107. package/dist/proxmox/acl.d.ts.map +1 -1
  108. package/dist/proxmox/distilled-pve.d.ts +89 -0
  109. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  110. package/dist/proxmox/index.js +663 -609
  111. package/dist/proxmox/index.js.map +12 -11
  112. package/dist/proxmox/members.d.ts +29 -10
  113. package/dist/proxmox/members.d.ts.map +1 -1
  114. package/docs/caddy-admin.md +1 -1
  115. package/docs/caddy.md +3 -2
  116. package/docs/credentials.md +16 -0
  117. package/docs/litellm.md +21 -13
  118. package/docs/paperless.md +12 -0
  119. package/docs/peers.md +5 -0
  120. package/docs/repo-policy.md +16 -1
  121. package/docs/repository-ruleset.md +53 -13
  122. package/docs/upstream-conformance.md +126 -10
  123. package/package.json +9 -2
  124. package/src/caddy/admin-address.ts +71 -0
  125. package/src/caddy/admin-calls.ts +48 -100
  126. package/src/caddy/admin.ts +37 -41
  127. package/src/caddy/adopt-scope.test.ts +1 -1
  128. package/src/caddy/caddy-http-client.ts +229 -0
  129. package/src/caddy/config-adopt-provider.test.ts +139 -0
  130. package/src/caddy/config-adopt.test.ts +60 -126
  131. package/src/caddy/config-errors.ts +47 -0
  132. package/src/caddy/config-lifecycle.test.ts +55 -42
  133. package/src/caddy/config-lifecycle.ts +72 -162
  134. package/src/caddy/config-reconcile.ts +138 -0
  135. package/src/caddy/config.ts +36 -42
  136. package/src/caddy/fake-caddy.ts +20 -2
  137. package/src/caddy/index.ts +3 -8
  138. package/src/caddy/local-admin.test.ts +35 -21
  139. package/src/caddy/local-admin.ts +60 -167
  140. package/src/caddy/providers.test.ts +14 -6
  141. package/src/caddy/providers.ts +20 -4
  142. package/src/github/declare-repo-baseline.ts +6 -6
  143. package/src/github/index.ts +10 -4
  144. package/src/github/repo-baseline-data.test.ts +13 -0
  145. package/src/github/repo-baseline-data.ts +11 -2
  146. package/src/github/repo-policy-stack.test.ts +59 -0
  147. package/src/github/repo-policy.ts +3 -2
  148. package/src/github/repository-ruleset-errors.ts +51 -4
  149. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  150. package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
  151. package/src/github/repository-ruleset-form.ts +7 -3
  152. package/src/github/repository-ruleset-guards.ts +11 -4
  153. package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
  154. package/src/github/repository-ruleset-narrowing.test.ts +210 -0
  155. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  156. package/src/github/repository-ruleset-octokit.ts +121 -30
  157. package/src/github/repository-ruleset-probe.ts +2 -1
  158. package/src/github/repository-ruleset-props.ts +95 -0
  159. package/src/github/repository-ruleset-providers.ts +46 -0
  160. package/src/github/repository-ruleset-reconcile.ts +35 -0
  161. package/src/github/repository-ruleset-wire.test.ts +38 -27
  162. package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
  163. package/src/github/repository-ruleset.ts +53 -43
  164. package/src/litellm/fake-litellm.ts +83 -78
  165. package/src/litellm/fake-stack.ts +13 -5
  166. package/src/litellm/index.ts +7 -21
  167. package/src/litellm/operations.ts +149 -0
  168. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  169. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  170. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  171. package/src/litellm/pass-through-endpoint.ts +21 -31
  172. package/src/litellm/pass-through-errors.ts +37 -0
  173. package/src/litellm/pass-through-form.ts +20 -10
  174. package/src/litellm/providers.ts +8 -7
  175. package/src/paperless/custom-field.test.ts +49 -40
  176. package/src/paperless/custom-field.ts +57 -33
  177. package/src/paperless/document-type.test.ts +48 -0
  178. package/src/paperless/document-type.ts +51 -22
  179. package/src/paperless/fake-paperless.ts +63 -72
  180. package/src/paperless/index.ts +7 -13
  181. package/src/paperless/matching-locate.test.ts +102 -0
  182. package/src/paperless/matching-locate.ts +80 -86
  183. package/src/paperless/matching-types.ts +49 -16
  184. package/src/paperless/matching.ts +77 -90
  185. package/src/paperless/providers.ts +14 -10
  186. package/src/paperless/storage-path.test.ts +56 -0
  187. package/src/paperless/storage-path.ts +53 -24
  188. package/src/paperless/tag-identity.test.ts +127 -0
  189. package/src/paperless/tag.test.ts +100 -0
  190. package/src/paperless/tag.ts +62 -32
  191. package/src/proxmox/acl.test.ts +145 -0
  192. package/src/proxmox/acl.ts +128 -150
  193. package/src/proxmox/distilled-pve.test.ts +231 -0
  194. package/src/proxmox/distilled-pve.ts +147 -0
  195. package/src/proxmox/members-timeout.test.ts +161 -0
  196. package/src/proxmox/members.test.ts +9 -0
  197. package/src/proxmox/members.ts +36 -2
  198. package/dist/litellm/client.d.ts +0 -76
  199. package/dist/litellm/client.d.ts.map +0 -1
  200. package/dist/litellm/credentials.d.ts +0 -48
  201. package/dist/litellm/credentials.d.ts.map +0 -1
  202. package/dist/litellm/generated/pass-through.d.ts +0 -71
  203. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  204. package/dist/paperless/client.d.ts +0 -37
  205. package/dist/paperless/client.d.ts.map +0 -1
  206. package/dist/paperless/credentials.d.ts +0 -40
  207. package/dist/paperless/credentials.d.ts.map +0 -1
  208. package/dist/paperless/errors.d.ts +0 -49
  209. package/dist/paperless/errors.d.ts.map +0 -1
  210. package/src/litellm/client.ts +0 -195
  211. package/src/litellm/credentials.ts +0 -73
  212. package/src/litellm/generated/pass-through.ts +0 -73
  213. package/src/paperless/client.ts +0 -88
  214. package/src/paperless/credentials.ts +0 -69
  215. package/src/paperless/errors.ts +0 -62
  216. package/src/paperless/matching-identity.test.ts +0 -194
  217. package/src/paperless/matching.test.ts +0 -128
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `Caddy.Config` — a running Caddy's whole config, declared as Caddyfile text and applied through
3
- * Caddy's own admin API (decision 23).
3
+ * Caddy's own admin API (decision 23), via `@distilled.cloud/caddy`'s typed `admin` operations.
4
4
  *
5
5
  * - create / update — `POST /adapt` (validate), `POST /load` with `text/caddyfile` (a graceful
6
6
  * reload; Caddy keeps the old config if it refuses the new one, and the error names why), then
@@ -20,18 +20,21 @@
20
20
  * the deploy runs with `--adopt`. A Caddy serving nothing reads as absent: a create. Where the
21
21
  * engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
22
22
  * ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
23
- * ⚠️ WHICH CADDY is the transport's (providers.ts), not a prop: one CaddyAdmin per stack.
23
+ * ⚠️ WHICH CADDY is the transport's (providers.ts): `CaddyAdminService` plus the `Credentials`/
24
+ * `HttpClient` layers `caddyAdminLayer` merges — one per stack, not a prop.
24
25
  */
25
26
  import { Resource } from 'alchemy';
26
27
  import { Unowned } from 'alchemy/AdoptPolicy';
27
28
  import { isResolved } from 'alchemy/Diff';
28
29
  import * as Provider from 'alchemy/Provider';
29
30
  import * as Effect from 'effect/Effect';
30
- import { lift, resolvedString } from '../launchd/host-effect.ts';
31
+ import { resolvedString } from '../launchd/host-effect.ts';
31
32
  import { adoptEnabled } from '../ownership/adopt.ts';
32
- import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
33
+ import { CaddyAdminService } from './admin.ts';
34
+ import { isUnreachable } from './caddy-http-client.ts';
33
35
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
34
- import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
36
+ import { probeLive, readLive } from './config-lifecycle.ts';
37
+ import { diffConfig, reconcileConfig } from './config-reconcile.ts';
35
38
 
36
39
  export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
37
40
 
@@ -45,9 +48,6 @@ export const CaddyConfig = Resource<CaddyConfig>('Caddy.Config', {
45
48
  defaultRemovalPolicy: 'retain',
46
49
  });
47
50
 
48
- const unreachable = (error: Error): error is CaddyUnreachableError =>
49
- error instanceof CaddyUnreachableError;
50
-
51
51
  /**
52
52
  * ⚠️ A STOPPED CADDY MUST NOT FAIL THE PLAN THAT BRINGS IT BACK. Its launchd job usually sits in the
53
53
  * same stack; if every plan died on `ECONNREFUSED`, the fix to that job could never be deployed.
@@ -56,10 +56,11 @@ const unreachable = (error: Error): error is CaddyUnreachableError =>
56
56
  */
57
57
  const planWithoutCaddy =
58
58
  <A>(admin: { endpoint: string }, fallback: A) =>
59
- (error: Error) =>
59
+ (error: unknown) =>
60
60
  Effect.as(
61
61
  Effect.logWarning(
62
- `Caddy.Config: no Caddy at ${admin.endpoint} (${error.message}); planning without it — ` +
62
+ `Caddy.Config: no Caddy at ${admin.endpoint} ` +
63
+ `(${error instanceof Error ? error.message : String(error)}); planning without it — ` +
63
64
  'the Caddyfile is validated when the deploy reaches Caddy',
64
65
  ),
65
66
  fallback,
@@ -72,23 +73,20 @@ export const CaddyConfigProvider = () =>
72
73
  const admin = yield* CaddyAdminService;
73
74
  /** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
74
75
  const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
75
- Effect.flatMap(
76
- lift(() => probeLive(admin, caddyfile, sourceFile)),
77
- (found) => {
78
- if (found === undefined) return Effect.succeed(undefined);
79
- if (found.ours) return Effect.succeed(found.attributes);
80
- const unowned = Unowned(found.attributes);
81
- return found.unchecked === undefined
82
- ? Effect.succeed(unowned)
83
- : Effect.as(
84
- Effect.logWarning(
85
- `Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
86
- `with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
87
- ),
88
- unowned,
89
- );
90
- },
91
- );
76
+ Effect.flatMap(probeLive(caddyfile, sourceFile), (found) => {
77
+ if (found === undefined) return Effect.succeed(undefined);
78
+ if (found.ours) return Effect.succeed(found.attributes);
79
+ const unowned = Unowned(found.attributes);
80
+ return found.unchecked === undefined
81
+ ? Effect.succeed(unowned)
82
+ : Effect.as(
83
+ Effect.logWarning(
84
+ `Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
85
+ `with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
86
+ ),
87
+ unowned,
88
+ );
89
+ });
92
90
  return CaddyConfig.Provider.of({
93
91
  /** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
94
92
  list: () => Effect.succeed([]),
@@ -100,21 +98,20 @@ export const CaddyConfigProvider = () =>
100
98
  * loopback), but WHOSE CONFIG it runs is not: a person, another tool or another stack.
101
99
  */
102
100
  read: ({ olds, output }) =>
103
- (output === undefined
104
- ? probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
105
- : lift(() => readLive(admin, resolvedString(olds, 'sourceFile')))
106
- ).pipe(
101
+ Effect.gen(function* () {
102
+ return output === undefined
103
+ ? yield* probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
104
+ : yield* readLive(resolvedString(olds, 'sourceFile'));
105
+ }).pipe(
107
106
  // ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
108
- Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
107
+ Effect.catchIf(isUnreachable, planWithoutCaddy(admin, undefined)),
109
108
  ),
110
109
 
111
110
  diff: ({ news, output }) => {
112
111
  if (output === undefined) return Effect.succeed(undefined);
113
112
  const update = planWithoutCaddy(admin, { action: 'update' as const });
114
113
  if (isResolved(news)) {
115
- return lift(() => diffConfig(admin, news, output)).pipe(
116
- Effect.catchIf(unreachable, update),
117
- );
114
+ return diffConfig(news, output).pipe(Effect.catchIf(isUnreachable, update));
118
115
  }
119
116
  /**
120
117
  * ⚠️ `sourceFile` is an Output while its HostFile changes in the same deploy
@@ -124,10 +121,9 @@ export const CaddyConfigProvider = () =>
124
121
  */
125
122
  const caddyfile = resolvedString(news, 'caddyfile');
126
123
  if (caddyfile === undefined) return Effect.succeed(undefined);
127
- return lift(async () => {
128
- await diffConfig(admin, { caddyfile }, output);
129
- return { action: 'update' as const };
130
- }).pipe(Effect.catchIf(unreachable, update));
124
+ return Effect.as(diffConfig({ caddyfile }, output), { action: 'update' as const }).pipe(
125
+ Effect.catchIf(isUnreachable, update),
126
+ );
131
127
  },
132
128
 
133
129
  reconcile: ({ fqn, news, output }) =>
@@ -139,9 +135,7 @@ export const CaddyConfigProvider = () =>
139
135
  const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
140
136
  const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
141
137
  const stored = output === undefined ? {} : { stored: output.configSha256 };
142
- const applied = yield* lift(() =>
143
- reconcileConfig(admin, news, { takeOver, ...stored }),
144
- );
138
+ const applied = yield* reconcileConfig(news, { takeOver, ...stored });
145
139
  for (const warning of applied.warnings) {
146
140
  yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
147
141
  }
@@ -17,7 +17,9 @@
17
17
  * ★ `listenPort` is the port Caddy BELIEVES it listens on — its Host check uses it — while the
18
18
  * fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
19
19
  */
20
- import type { CaddyAdmin } from './admin.ts';
20
+ import * as Effect from 'effect/Effect';
21
+ import type * as Caddy from '@distilled.cloud/caddy';
22
+ import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
21
23
  import { localCaddyAdmin } from './local-admin.ts';
22
24
 
23
25
  export type Seen = {
@@ -128,6 +130,10 @@ export const fakeCaddy = (
128
130
  }
129
131
  if (url.pathname !== '/load') return json(404, { error: 'not found' });
130
132
  const prefix = adapted.warnings.length > 0 ? JSON.stringify(adapted.warnings) : '';
133
+ // ⚠️ TEST-ONLY SCENARIO, no Caddy source it reproduces: a `/load` that answers 200 with no
134
+ // applied change at all (not even the embedded-error 200 the ⛔ above describes) — what
135
+ // config-lifecycle.ts's read-back-and-insist check exists to catch, per its own doc.
136
+ if (body.includes('SWALLOW_LOAD')) return new Response('', { status: 200 });
131
137
  if (body.includes('PROVISION_ERROR')) {
132
138
  const error = JSON.stringify({
133
139
  error: 'loading config: provision http: listen tcp :443: address already in use',
@@ -168,7 +174,9 @@ export const fakeCaddy = (
168
174
  * A Caddy reached on any other port needs `admin <address>` declared, or the first load would
169
175
  * move it (admin-guard.ts).
170
176
  */
171
- export const fakeDefaultCaddy = (running?: unknown): { admin: CaddyAdmin; caddy: FakeCaddy } => {
177
+ export const fakeDefaultCaddy = (
178
+ running?: unknown,
179
+ ): { admin: CaddyTransport; caddy: FakeCaddy } => {
172
180
  const caddy = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
173
181
  const admin = localCaddyAdmin({
174
182
  address: caddy.address,
@@ -177,3 +185,13 @@ export const fakeDefaultCaddy = (running?: unknown): { admin: CaddyAdmin; caddy:
177
185
  });
178
186
  return { admin, caddy };
179
187
  };
188
+
189
+ /**
190
+ * Runs an Effect that needs `CaddyAdminService` and `@distilled.cloud/caddy`'s own `Credentials`/
191
+ * `HttpClient` against one transport — the one line every test in this directory used to spend on
192
+ * `Effect.provide(caddyAdminLayer(admin))` before this helper existed.
193
+ */
194
+ export const runCaddy = <A, E>(
195
+ effect: Effect.Effect<A, E, CaddyAdminService | Caddy.CaddyOpContext>,
196
+ admin: CaddyTransport,
197
+ ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(caddyAdminLayer(admin))));
@@ -19,14 +19,9 @@ export {
19
19
  verifierProblems,
20
20
  type AccessForwardAuthProps,
21
21
  } from './access-forward-auth.ts';
22
- export type {
23
- CaddyAdmin,
24
- CaddyAdminListener,
25
- CaddyAdminRequest,
26
- CaddyAdminResponse,
27
- } from './admin.ts';
28
- export { CaddyAdminService, CaddyUnreachableError, caddyAdminLayer } from './admin.ts';
29
- export { CaddyAdminError } from './admin-calls.ts';
22
+ export type { CaddyAdminListener, CaddyTarget, CaddyTransport } from './admin.ts';
23
+ export { CaddyAdminService, caddyAdminLayer } from './admin.ts';
24
+ export { isUnreachable } from './caddy-http-client.ts';
30
25
  export type { CaddyConfigAttributes, CaddyConfigProps } from './config.ts';
31
26
  export { CaddyConfig, CaddyConfigProvider } from './config.ts';
32
27
  export type { LocalCaddyAdminOptions } from './local-admin.ts';
@@ -7,9 +7,10 @@ import { rmSync } from 'node:fs';
7
7
  import { createServer } from 'node:net';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
- import { CaddyUnreachableError } from './admin.ts';
11
- import { readRunningConfig } from './admin-calls.ts';
12
- import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
10
+ import * as HttpClientError from 'effect/unstable/http/HttpClientError';
11
+ import { loadCaddyfile, readRunningConfig } from './admin-calls.ts';
12
+ import { isUnreachable } from './caddy-http-client.ts';
13
+ import { type FakeCaddy, fakeCaddy, runCaddy } from './fake-caddy.ts';
13
14
  import { DEFAULT_ADMIN_ADDRESS, localCaddyAdmin, parseAdminAddress } from './local-admin.ts';
14
15
 
15
16
  let fake: FakeCaddy | undefined;
@@ -83,7 +84,7 @@ describe('requests', () => {
83
84
  test('over TCP: Host and Origin as the Caddy CLI sends them, so the checks pass', async () => {
84
85
  fake = fakeCaddy();
85
86
  const admin = localCaddyAdmin({ address: fake.address, retries: 0 });
86
- expect(await readRunningConfig(admin)).toBeNull();
87
+ expect(await runCaddy(readRunningConfig(), admin)).toBeNull();
87
88
  const host = `127.0.0.1:${String(fake.port)}`;
88
89
  expect(fake.seen[0]?.headers.get('host')).toBe(host);
89
90
  expect(fake.seen[0]?.headers.get('origin')).toBe(`http://${host}`);
@@ -92,29 +93,42 @@ describe('requests', () => {
92
93
  test('a narrowed `origins` needs hostHeader — without it Caddy answers 403', async () => {
93
94
  fake = fakeCaddy({ origins: ['caddy-admin.example:2019'] });
94
95
  const plain = localCaddyAdmin({ address: fake.address, retries: 0 });
95
- await expect(readRunningConfig(plain)).rejects.toThrow(/403: host not allowed/);
96
+ await expect(runCaddy(readRunningConfig(), plain)).rejects.toThrow(/host not allowed/);
96
97
  const named = localCaddyAdmin({
97
98
  address: fake.address,
98
99
  hostHeader: 'caddy-admin.example:2019',
99
100
  retries: 0,
100
101
  });
101
- expect(await readRunningConfig(named)).toBeNull();
102
+ expect(await runCaddy(readRunningConfig(), named)).toBeNull();
102
103
  });
103
104
 
104
- test('a caller cannot override the Host and Origin the checks depend on', async () => {
105
+ test("an operation's own headers never override the Host and Origin the checks depend on", async () => {
106
+ // ⚠️ There is no raw `.request()` anymore for a caller to hand Host/Origin to — every header
107
+ // an operation sends of its own (loadConfig's Content-Type, Cache-Control, …) is set by
108
+ // distilled's `buildRequest` from the SCHEMA, never by a caller, and Host/Origin are set by
109
+ // `CaddyProtocol.encode` from `Credentials` alone, unconditionally, after that — so the same
110
+ // invariant now holds by construction rather than by a check in this transport.
105
111
  fake = fakeCaddy();
106
112
  const admin = localCaddyAdmin({ address: fake.address, retries: 0 });
107
- const headers = { Host: 'evil.example:2019', Origin: 'http://evil.example:2019' };
108
- expect((await admin.request({ headers, method: 'GET', path: '/config/' })).status).toBe(200);
109
- expect(fake.seen[0]?.headers.get('host')).toBe(`127.0.0.1:${String(fake.port)}`);
113
+ await runCaddy(loadCaddyfile('respond ok'), admin);
114
+ const host = `127.0.0.1:${String(fake.port)}`;
115
+ const load = fake.seen.find((call) => call.path === '/load');
116
+ expect(load?.headers.get('host')).toBe(host);
117
+ expect(load?.headers.get('origin')).toBe(`http://${host}`);
110
118
  });
111
119
 
112
- test('over a unix socket, with no Origin', async () => {
120
+ test('over a unix socket', async () => {
113
121
  const path = socketPath('req');
114
122
  fake = fakeCaddy({ running: { apps: {} }, unix: path });
115
123
  const admin = localCaddyAdmin({ address: fake.address, retries: 0 });
116
- expect(await readRunningConfig(admin)).toEqual({ apps: {} });
117
- expect(fake.seen[0]?.headers.has('origin')).toBe(false);
124
+ expect(await runCaddy(readRunningConfig(), admin)).toEqual({ apps: {} });
125
+ // ⚠️ MEASURED 2026-09-23: `@distilled.cloud/caddy`'s `CaddyProtocol.encode` sends Host AND
126
+ // Origin on every call, unconditionally — unlike the old hand-rolled transport, which sent
127
+ // no Origin over a unix socket (Caddy's own CLI doesn't either). Caddy skips the Host check
128
+ // entirely on unix listeners either way (admin.go allowedOrigins), so this changes nothing
129
+ // the default (no `enforce_origin`) fake or a real unix-socket Caddy checks.
130
+ expect(fake.seen[0]?.headers.get('host')).toBe('127.0.0.1');
131
+ expect(fake.seen[0]?.headers.get('origin')).toBe('http://127.0.0.1');
118
132
  rmSync(path, { force: true });
119
133
  });
120
134
 
@@ -124,7 +138,7 @@ describe('requests', () => {
124
138
  const late = setTimeout(() => {
125
139
  fake = fakeCaddy({ unix: path });
126
140
  }, 60);
127
- expect(await readRunningConfig(admin)).toBeNull();
141
+ expect(await runCaddy(readRunningConfig(), admin)).toBeNull();
128
142
  clearTimeout(late);
129
143
  rmSync(path, { force: true });
130
144
  });
@@ -143,18 +157,18 @@ describe('requests', () => {
143
157
  retries: 3,
144
158
  retryDelayMs: 1,
145
159
  });
146
- const failure = admin.request({ body: 'x', method: 'POST', path: '/load' });
147
- await expect(failure).rejects.toThrow(/Caddy admin POST \/load/);
148
- await expect(failure).rejects.not.toBeInstanceOf(CaddyUnreachableError);
160
+ const failure = runCaddy(loadCaddyfile('respond ok'), admin);
161
+ await expect(failure).rejects.toBeInstanceOf(HttpClientError.HttpClientError);
162
+ await expect(failure.catch((error: unknown) => isUnreachable(error))).resolves.toBe(false);
149
163
  expect(accepted).toBe(1);
150
164
  server.close();
151
165
  });
152
166
 
153
- test('gives up after the retries with CaddyUnreachableError, naming the address', async () => {
167
+ test('gives up after the retries, still narrowable as unreachable, naming the address', async () => {
154
168
  const path = socketPath('never');
155
169
  const admin = localCaddyAdmin({ address: `unix://${path}`, retries: 1, retryDelayMs: 1 });
156
- const failure = readRunningConfig(admin);
157
- await expect(failure).rejects.toBeInstanceOf(CaddyUnreachableError);
158
- await expect(failure).rejects.toThrow(/Caddy admin GET \/config\/ at unix:\/\//);
170
+ const failure = runCaddy(readRunningConfig(), admin);
171
+ await expect(failure).rejects.toBeInstanceOf(HttpClientError.HttpClientError);
172
+ await expect(failure.catch((error: unknown) => isUnreachable(error))).resolves.toBe(true);
159
173
  });
160
174
  });
@@ -1,189 +1,82 @@
1
1
  /**
2
- * The real CaddyAdmin: this machine's Caddy, over loopback TCP or a unix socket.
2
+ * The real Caddy transport: this machine's Caddy, over loopback TCP or a unix socket — the
3
+ * `Credentials` and `HttpClient.HttpClient` layers `@distilled.cloud/caddy`'s typed operations run
4
+ * against (admin-calls.ts), plus the message/guard metadata `CaddyAdminService` carries.
3
5
  *
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
- * a Mac host's Caddy 2.11.4: any other Host gets 403 `host not allowed`, a forwarded port included —
16
- * hence `hostHeader`.
6
+ * Address parsing (`parseAdminAddress`) is pure — admin-address.ts. The wire transport itself
7
+ * (retries, timeouts, the unix-socket dial) is caddy-http-client.ts; this file is the seam between
8
+ * the two and `@distilled.cloud/caddy`'s own `Credentials`.
17
9
  */
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';
10
+ import * as Layer from 'effect/Layer';
11
+ import * as Caddy from '@distilled.cloud/caddy';
12
+ import type { CaddyTransport } from './admin.ts';
13
+ import { DEFAULT_ADMIN_ADDRESS, parseAdminAddress } from './admin-address.ts';
14
+ import { type LocalHttpClientOptions, makeLocalHttpClient } from './caddy-http-client.ts';
15
+
16
+ export { DEFAULT_ADMIN_ADDRESS, parseAdminAddress } from './admin-address.ts';
17
+ export { isUnreachable } from './caddy-http-client.ts';
25
18
 
26
- export type LocalCaddyAdminOptions = {
19
+ export type LocalCaddyAdminOptions = LocalHttpClientOptions & {
27
20
  /**
28
21
  * `http://<loopback>:<port>` or `unix:///absolute/path.sock`.
29
22
  * @default 'http://127.0.0.1:2019' — Caddy's default admin listener (`localhost:2019`).
30
23
  */
31
24
  readonly address?: string;
32
25
  /**
33
- * The Host (and Origin) to send over TCP, when it is not the address's own: the Caddyfile
26
+ * The Host (and, over TCP, Origin) to send when it is not the address's own: the Caddyfile
34
27
  * 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`.
28
+ * the Host against ITS port, not yours). Form `host:port`. Over a unix socket this still sets
29
+ * the Host header `@distilled.cloud/caddy`'s protocol always sends (Caddy skips the Host check
30
+ * on unix listeners regardless — admin.go allowedOrigins) — defaults to `127.0.0.1`, matching
31
+ * the Caddy CLI's own unix-socket calls (cmd/commandfuncs.go AdminAPIRequest).
36
32
  */
37
33
  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
34
  };
112
35
 
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 => {
36
+ /**
37
+ * A transport to this machine's Caddy — feed it to `caddyProviders()`, or build the layer by hand
38
+ * with `CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(localCaddyAdmin())))` (never
39
+ * plain `Layer.provide` — admin.ts's own doc on `caddyAdminLayer` says why).
40
+ *
41
+ * ⛔ LOOPBACK OR A UNIX SOCKET, NOTHING ELSE. The admin API is unauthenticated plaintext; an
42
+ * address off this machine means it is reachable from the network, which decision 23 forbids.
43
+ * `parseAdminAddress` refuses anything else.
44
+ */
45
+ export const localCaddyAdmin = (options: LocalCaddyAdminOptions = {}): CaddyTransport => {
160
46
  const address = options.address ?? DEFAULT_ADMIN_ADDRESS;
161
47
  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' };
48
+ // `@distilled.cloud/caddy`'s protocol always builds `Host`/Origin from `apiBaseUrl` (or this
49
+ // override) — never from the dial target, which caddy-http-client.ts ignores the URL's host/port
50
+ // for anyway over a unix socket. `http://localhost` is a placeholder base for the request-URL
51
+ // machinery (`buildRequest` needs a syntactically valid `http://` URL to join `/config/…` onto);
52
+ // the hostHeader default `127.0.0.1` is what actually reaches Caddy's Host check, matching what
53
+ // the Caddy CLI sends over a unix socket.
54
+ const credentials =
55
+ target.kind === 'unix'
56
+ ? Caddy.fromApiBaseUrl({
57
+ apiBaseUrl: 'http://localhost',
58
+ hostHeader: options.hostHeader ?? '127.0.0.1',
59
+ })
60
+ : Caddy.fromApiBaseUrl({
61
+ apiBaseUrl: address,
62
+ ...(options.hostHeader === undefined ? {} : { hostHeader: options.hostHeader }),
63
+ });
169
64
  return {
170
65
  endpoint: address,
66
+ // ⚠️ `Caddy.Retry.Retry` DISABLED, not left at the SDK's default. MEASURED 2026-09-23: core's
67
+ // `isTransientError` treats ANY `HttpClientError` with a `TransportError` reason as transient
68
+ // — including an ECONNRESET well after the connection was accepted — and every generated
69
+ // operation already retries under that default (`API.make({ retry: Retry.Retry })`,
70
+ // services/admin.ts). Left in place, it retried a `POST /load` that had already reached the
71
+ // far side (a socket that accepted, then reset) — the exact re-send admin-calls.ts's module
72
+ // doc says must never happen — NINE times over, compounding with caddy-http-client.ts's own
73
+ // narrower retry below it. This transport's retry (ECONNREFUSED/ENOENT only — "nothing
74
+ // accepted the connection yet") is the one retry policy Caddy's admin API gets.
75
+ layer: Layer.mergeAll(
76
+ credentials,
77
+ makeLocalHttpClient(target, options),
78
+ Layer.succeed(Caddy.Retry.Retry, { while: () => false }),
79
+ ),
171
80
  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
81
  };
189
82
  };
@@ -7,7 +7,8 @@ import * as Output from 'alchemy/Output';
7
7
  import { Stack } from 'alchemy/Stack';
8
8
  import * as Effect from 'effect/Effect';
9
9
  import * as Layer from 'effect/Layer';
10
- import { caddyAdminLayer } from './admin.ts';
10
+ import type * as Caddy from '@distilled.cloud/caddy';
11
+ import { type CaddyAdminService, caddyAdminLayer } from './admin.ts';
11
12
  import { CaddyConfig, CaddyConfigProvider } from './config.ts';
12
13
  import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
13
14
  import * as barrel from './index.ts';
@@ -30,7 +31,9 @@ const handler = <F>(name: string, fn: F | undefined): F => {
30
31
  };
31
32
 
32
33
  const withProvider = <A>(
33
- use: (p: Effect.Success<typeof CaddyConfig.Provider>) => Effect.Effect<A, unknown>,
34
+ use: (
35
+ p: Effect.Success<typeof CaddyConfig.Provider>,
36
+ ) => Effect.Effect<A, unknown, CaddyAdminService | Caddy.CaddyOpContext>,
34
37
  address?: string,
35
38
  ) => {
36
39
  // ★ A Caddy on its default :2019 behind a forward, as in config-lifecycle.test.ts.
@@ -46,7 +49,7 @@ const withProvider = <A>(
46
49
  return Effect.runPromise(
47
50
  Effect.gen(function* () {
48
51
  return yield* use(yield* CaddyConfig.Provider);
49
- }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin))))),
52
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(admin))))),
50
53
  );
51
54
  };
52
55
 
@@ -159,7 +162,11 @@ describe('a Caddy that is down', () => {
159
162
  }),
160
163
  closedPort(),
161
164
  );
162
- await expect(applying).rejects.toThrow(/Caddy admin POST \/adapt at http:\/\/127\.0\.0\.1/);
165
+ // ★ Reconcile never catches `isUnreachable` (config.ts): the raw, still-typed
166
+ // `HttpClientError` propagates as-is, its own `.message` naming the operation and URL.
167
+ await expect(applying).rejects.toThrow(
168
+ /Transport error \(POST http:\/\/127\.0\.0\.1.*\/adapt\)/,
169
+ );
163
170
  });
164
171
 
165
172
  test('a bad Caddyfile still fails the plan when Caddy IS up (only unreachability is forgiven)', async () => {
@@ -214,11 +221,9 @@ describe('caddyWithFile', () => {
214
221
  describe('the barrel', () => {
215
222
  test('exports the public API and not the internals', () => {
216
223
  expect(Object.keys(barrel).sort()).toEqual([
217
- 'CaddyAdminError',
218
224
  'CaddyAdminService',
219
225
  'CaddyConfig',
220
226
  'CaddyConfigProvider',
221
- 'CaddyUnreachableError',
222
227
  'DEFAULT_ADMIN_ADDRESS',
223
228
  // Caddyfile TEXT for an Access-protected route, not a resource — see
224
229
  // access-forward-auth.ts. It pairs with @homeflare/cloudflare/access-auth.
@@ -226,6 +231,9 @@ describe('the barrel', () => {
226
231
  'caddyAdminLayer',
227
232
  'caddyProviders',
228
233
  'caddyWithFile',
234
+ // The type guard config.ts's plan-time escape hatch narrows unreachable failures with —
235
+ // exported so a consumer's own transport (an SSH forward, a remote runner) can reuse it.
236
+ 'isUnreachable',
229
237
  'localCaddyAdmin',
230
238
  'verifierProblems',
231
239
  ]);