@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.
- package/README.md +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/github/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +9 -5
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +161 -25
- package/dist/github/index.js.map +15 -13
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +37 -3
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +15 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
- package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +91 -0
- package/dist/github/repository-ruleset-props.d.ts.map +1 -0
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
- package/dist/github/repository-ruleset.d.ts +28 -34
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +9 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline.ts +6 -6
- package/src/github/index.ts +10 -4
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +3 -2
- package/src/github/repository-ruleset-errors.ts +51 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +11 -4
- package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
- package/src/github/repository-ruleset-narrowing.test.ts +210 -0
- package/src/github/repository-ruleset-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +95 -0
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-reconcile.ts +35 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
- package/src/github/repository-ruleset.ts +53 -43
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
package/src/caddy/config.ts
CHANGED
|
@@ -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)
|
|
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 {
|
|
31
|
+
import { resolvedString } from '../launchd/host-effect.ts';
|
|
31
32
|
import { adoptEnabled } from '../ownership/adopt.ts';
|
|
32
|
-
import { CaddyAdminService
|
|
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 {
|
|
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:
|
|
59
|
+
(error: unknown) =>
|
|
60
60
|
Effect.as(
|
|
61
61
|
Effect.logWarning(
|
|
62
|
-
`Caddy.Config: no Caddy at ${admin.endpoint}
|
|
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
|
-
|
|
77
|
-
(found)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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*
|
|
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
|
}
|
package/src/caddy/fake-caddy.ts
CHANGED
|
@@ -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
|
|
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 = (
|
|
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))));
|
package/src/caddy/index.ts
CHANGED
|
@@ -19,14 +19,9 @@ export {
|
|
|
19
19
|
verifierProblems,
|
|
20
20
|
type AccessForwardAuthProps,
|
|
21
21
|
} from './access-forward-auth.ts';
|
|
22
|
-
export type {
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
11
|
-
import { readRunningConfig } from './admin-calls.ts';
|
|
12
|
-
import {
|
|
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(/
|
|
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('
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
147
|
-
await expect(failure).rejects.
|
|
148
|
-
await expect(failure).
|
|
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
|
|
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(
|
|
158
|
-
await expect(failure
|
|
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
|
});
|
package/src/caddy/local-admin.ts
CHANGED
|
@@ -1,189 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The real
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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: (
|
|
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.
|
|
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
|
-
|
|
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
|
]);
|