@homeflare/alchemy 0.28.0 → 0.29.1

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 (221) 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 +8 -4
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +83 -24
  42. package/dist/github/index.js.map +14 -12
  43. package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
  44. package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
  45. package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
  46. package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
  47. package/dist/github/repo-auto-merge-gate.d.ts +54 -0
  48. package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
  49. package/dist/github/repo-baseline-data.d.ts +10 -1
  50. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  51. package/dist/github/repo-policy.d.ts +1 -1
  52. package/dist/github/repo-policy.d.ts.map +1 -1
  53. package/dist/github/repository-ruleset-errors.d.ts +3 -2
  54. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  55. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-guards.d.ts +3 -2
  57. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  59. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  60. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  61. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  62. package/dist/github/repository-ruleset-props.d.ts +7 -4
  63. package/dist/github/repository-ruleset-props.d.ts.map +1 -1
  64. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  65. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  66. package/dist/github/repository-ruleset.d.ts +26 -4
  67. package/dist/github/repository-ruleset.d.ts.map +1 -1
  68. package/dist/litellm/fake-litellm.d.ts +27 -16
  69. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  70. package/dist/litellm/fake-stack.d.ts +9 -4
  71. package/dist/litellm/fake-stack.d.ts.map +1 -1
  72. package/dist/litellm/index.d.ts +7 -5
  73. package/dist/litellm/index.d.ts.map +1 -1
  74. package/dist/litellm/index.js +53 -123
  75. package/dist/litellm/index.js.map +8 -8
  76. package/dist/litellm/operations.d.ts +60 -0
  77. package/dist/litellm/operations.d.ts.map +1 -0
  78. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  79. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  80. package/dist/litellm/pass-through-errors.d.ts +37 -0
  81. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  82. package/dist/litellm/pass-through-form.d.ts +11 -4
  83. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  84. package/dist/litellm/providers.d.ts +7 -5
  85. package/dist/litellm/providers.d.ts.map +1 -1
  86. package/dist/netbox/prefix.d.ts +4 -4
  87. package/dist/paperless/custom-field.d.ts +21 -21
  88. package/dist/paperless/custom-field.d.ts.map +1 -1
  89. package/dist/paperless/document-type.d.ts +34 -4
  90. package/dist/paperless/document-type.d.ts.map +1 -1
  91. package/dist/paperless/fake-paperless.d.ts +36 -19
  92. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  93. package/dist/paperless/index.d.ts +7 -2
  94. package/dist/paperless/index.d.ts.map +1 -1
  95. package/dist/paperless/index.js +164 -228
  96. package/dist/paperless/index.js.map +10 -13
  97. package/dist/paperless/matching-locate.d.ts +42 -20
  98. package/dist/paperless/matching-locate.d.ts.map +1 -1
  99. package/dist/paperless/matching-types.d.ts +47 -18
  100. package/dist/paperless/matching-types.d.ts.map +1 -1
  101. package/dist/paperless/matching.d.ts +30 -27
  102. package/dist/paperless/matching.d.ts.map +1 -1
  103. package/dist/paperless/providers.d.ts +13 -7
  104. package/dist/paperless/providers.d.ts.map +1 -1
  105. package/dist/paperless/storage-path.d.ts +34 -4
  106. package/dist/paperless/storage-path.d.ts.map +1 -1
  107. package/dist/paperless/tag.d.ts +43 -8
  108. package/dist/paperless/tag.d.ts.map +1 -1
  109. package/dist/proxmox/acl.d.ts +30 -23
  110. package/dist/proxmox/acl.d.ts.map +1 -1
  111. package/dist/proxmox/distilled-pve.d.ts +89 -0
  112. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  113. package/dist/proxmox/index.js +663 -609
  114. package/dist/proxmox/index.js.map +12 -11
  115. package/dist/proxmox/members.d.ts +29 -10
  116. package/dist/proxmox/members.d.ts.map +1 -1
  117. package/docs/caddy-admin.md +1 -1
  118. package/docs/caddy.md +3 -2
  119. package/docs/credentials.md +16 -0
  120. package/docs/litellm.md +21 -13
  121. package/docs/paperless.md +12 -0
  122. package/docs/peers.md +5 -0
  123. package/docs/repo-policy.md +16 -1
  124. package/docs/repository-ruleset.md +53 -13
  125. package/docs/upstream-conformance.md +126 -10
  126. package/package.json +5 -2
  127. package/src/caddy/admin-address.ts +71 -0
  128. package/src/caddy/admin-calls.ts +48 -100
  129. package/src/caddy/admin.ts +37 -41
  130. package/src/caddy/adopt-scope.test.ts +1 -1
  131. package/src/caddy/caddy-http-client.ts +229 -0
  132. package/src/caddy/config-adopt-provider.test.ts +139 -0
  133. package/src/caddy/config-adopt.test.ts +60 -126
  134. package/src/caddy/config-errors.ts +47 -0
  135. package/src/caddy/config-lifecycle.test.ts +55 -42
  136. package/src/caddy/config-lifecycle.ts +72 -162
  137. package/src/caddy/config-reconcile.ts +138 -0
  138. package/src/caddy/config.ts +36 -42
  139. package/src/caddy/fake-caddy.ts +20 -2
  140. package/src/caddy/index.ts +3 -8
  141. package/src/caddy/local-admin.test.ts +35 -21
  142. package/src/caddy/local-admin.ts +60 -167
  143. package/src/caddy/providers.test.ts +14 -6
  144. package/src/caddy/providers.ts +20 -4
  145. package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
  146. package/src/github/declare-repo-baseline.ts +40 -7
  147. package/src/github/index.ts +8 -4
  148. package/src/github/repo-apply-order-fake-providers.ts +119 -0
  149. package/src/github/repo-apply-order-fake-stack.ts +74 -0
  150. package/src/github/repo-auto-merge-gate.ts +57 -0
  151. package/src/github/repo-baseline-data.test.ts +13 -0
  152. package/src/github/repo-baseline-data.ts +11 -2
  153. package/src/github/repo-policy-apply-order.test.ts +72 -0
  154. package/src/github/repo-policy-stack.test.ts +59 -0
  155. package/src/github/repo-policy.ts +31 -3
  156. package/src/github/repository-ruleset-errors.ts +6 -4
  157. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  158. package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
  159. package/src/github/repository-ruleset-form.ts +7 -3
  160. package/src/github/repository-ruleset-guards.ts +3 -2
  161. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  162. package/src/github/repository-ruleset-octokit.ts +121 -30
  163. package/src/github/repository-ruleset-probe.ts +2 -1
  164. package/src/github/repository-ruleset-props.ts +7 -4
  165. package/src/github/repository-ruleset-providers.ts +46 -0
  166. package/src/github/repository-ruleset-wire.test.ts +38 -27
  167. package/src/github/repository-ruleset.ts +27 -3
  168. package/src/litellm/fake-litellm.ts +83 -78
  169. package/src/litellm/fake-stack.ts +13 -5
  170. package/src/litellm/index.ts +7 -21
  171. package/src/litellm/operations.ts +149 -0
  172. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  173. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  174. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  175. package/src/litellm/pass-through-endpoint.ts +21 -31
  176. package/src/litellm/pass-through-errors.ts +37 -0
  177. package/src/litellm/pass-through-form.ts +20 -10
  178. package/src/litellm/providers.ts +8 -7
  179. package/src/paperless/custom-field.test.ts +49 -40
  180. package/src/paperless/custom-field.ts +57 -33
  181. package/src/paperless/document-type.test.ts +48 -0
  182. package/src/paperless/document-type.ts +51 -22
  183. package/src/paperless/fake-paperless.ts +63 -72
  184. package/src/paperless/index.ts +7 -13
  185. package/src/paperless/matching-locate.test.ts +102 -0
  186. package/src/paperless/matching-locate.ts +80 -86
  187. package/src/paperless/matching-types.ts +49 -16
  188. package/src/paperless/matching.ts +77 -90
  189. package/src/paperless/providers.ts +14 -10
  190. package/src/paperless/storage-path.test.ts +56 -0
  191. package/src/paperless/storage-path.ts +53 -24
  192. package/src/paperless/tag-identity.test.ts +127 -0
  193. package/src/paperless/tag.test.ts +100 -0
  194. package/src/paperless/tag.ts +62 -32
  195. package/src/proxmox/acl.test.ts +145 -0
  196. package/src/proxmox/acl.ts +128 -150
  197. package/src/proxmox/distilled-pve.test.ts +231 -0
  198. package/src/proxmox/distilled-pve.ts +147 -0
  199. package/src/proxmox/members-timeout.test.ts +161 -0
  200. package/src/proxmox/members.test.ts +9 -0
  201. package/src/proxmox/members.ts +36 -2
  202. package/dist/litellm/client.d.ts +0 -76
  203. package/dist/litellm/client.d.ts.map +0 -1
  204. package/dist/litellm/credentials.d.ts +0 -48
  205. package/dist/litellm/credentials.d.ts.map +0 -1
  206. package/dist/litellm/generated/pass-through.d.ts +0 -71
  207. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  208. package/dist/paperless/client.d.ts +0 -37
  209. package/dist/paperless/client.d.ts.map +0 -1
  210. package/dist/paperless/credentials.d.ts +0 -40
  211. package/dist/paperless/credentials.d.ts.map +0 -1
  212. package/dist/paperless/errors.d.ts +0 -49
  213. package/dist/paperless/errors.d.ts.map +0 -1
  214. package/src/litellm/client.ts +0 -195
  215. package/src/litellm/credentials.ts +0 -73
  216. package/src/litellm/generated/pass-through.ts +0 -73
  217. package/src/paperless/client.ts +0 -88
  218. package/src/paperless/credentials.ts +0 -69
  219. package/src/paperless/errors.ts +0 -62
  220. package/src/paperless/matching-identity.test.ts +0 -194
  221. package/src/paperless/matching.test.ts +0 -128
@@ -1,11 +1,4 @@
1
- /**
2
- * C1 cluster members: ordered failover, last-good preference, transport classification.
3
- *
4
- * ★ ANY MEMBER'S :8006 API MANAGES THE WHOLE CLUSTER — PVE proxies node paths itself — so the
5
- * provider reaches the cluster through whichever member is up, not through one pinned node.
6
- *
7
- * ⛔ NEVER cluster-c1.example.com: it resolves to 192.0.2.250, which nothing serves.
8
- */
1
+ import type * as Duration from 'effect/Duration';
9
2
  import * as Effect from 'effect/Effect';
10
3
  import * as HttpClient from 'effect/unstable/http/HttpClient';
11
4
  import type * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
@@ -14,6 +7,21 @@ import type { PveTarget } from './credentials.ts';
14
7
  export declare const PVE_API_PORT = 8006;
15
8
  /** Strict TLS, always :8006 — each node's certificate carries its mgmt name as a SAN. */
16
9
  export declare const pveApiBase: (member: string) => string;
10
+ /**
11
+ * Bound on ONE member attempt, shared by `client.ts`'s `executeOnCluster` and
12
+ * `distilled-pve.ts`'s `runPveWith` — both loop over `orderedMembers` and both call this file's
13
+ * classifiers, so one constant keeps them in the same failure-mode boundary.
14
+ *
15
+ * ⛔ MEASURED GAP, FIXED 2026-09-24: before this, NOTHING bounded a single attempt. A member that
16
+ * accepted the TCP connection but never answered hung the whole call forever — the for-loop
17
+ * never produced a failure for `isTransportFailure`/`isPreSendTransport` to classify, so it
18
+ * never advanced to a healthy member. `isTimeoutCause` below existed already (written for this
19
+ * exact case, per its own comment: "no answer after connect") but nothing ever called
20
+ * `Effect.timeout` to produce one.
21
+ * ★ TB4 (the live 3-member cluster, `homeflare-proxmox/src/target.ts`) waits at most 3× this
22
+ * before `PveClusterExhausted` — well under S26's ~90-second ceiling on a bounded wait.
23
+ */
24
+ export declare const MEMBER_TIMEOUT: Duration.Input;
17
25
  /** ⚠️ TESTS ONLY — resets sticky member choice between cases. */
18
26
  export declare const resetLastGoodForTest: () => void;
19
27
  export declare const noteGoodMember: (target: PveTarget, member: string) => void;
@@ -24,9 +32,16 @@ export declare const orderedMembers: (target: PveTarget) => readonly string[];
24
32
  * ★ TransportError | InvalidUrlError | EncodeError occur before a response exists; StatusCodeError
25
33
  * and kin include a response. client.execute does NOT filter status, so HTTP answers are never
26
34
  * TransportError — only the transport layer is.
35
+ * ★ `Cause.TimeoutError` (from `MEMBER_TIMEOUT` below, via `Effect.timeout`) counts too — a read
36
+ * with no side effect to duplicate fails over on ANY transport failure, a hang included.
27
37
  */
28
38
  export declare const isTransportFailure: (cause: unknown) => boolean;
29
- /** Pre-send only: connection refused, DNS, a certificate refused — never a timeout after connect. */
39
+ /**
40
+ * Pre-send only: connection refused, DNS, a certificate refused — never a timeout after connect.
41
+ * ⛔ `Cause.TimeoutError` (MEMBER_TIMEOUT firing) falls through to `false` here on purpose, same
42
+ * as any other non-`HttpClientError` cause: the request may already have reached the member, so
43
+ * a write must not repeat it elsewhere.
44
+ */
30
45
  export declare const isPreSendTransport: (cause: unknown) => boolean;
31
46
  export declare const describeTransport: (cause: unknown) => string;
32
47
  export type MemberAttempt = {
@@ -46,8 +61,12 @@ export type PveMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
46
61
  * ★ Reads fail over on any transport failure, including timeout — no side effect to duplicate.
47
62
  * MEASURED 2026-09-14: full C1 plans stayed all-noop with the first member refused (127.0.0.1)
48
63
  * and with it unresolvable (n9.invalid).
64
+ *
65
+ * @param timeout Bound on ONE member's attempt — defaults to `MEMBER_TIMEOUT`. A test passes a
66
+ * short one to prove failover happens without waiting out the real bound; production code never
67
+ * overrides it.
49
68
  */
50
- export declare const executeOnCluster: (target: PveTarget, method: PveMethod, path: string, build: (apiBase: string) => HttpClientRequest.HttpClientRequest) => Effect.Effect<HttpClientResponse.HttpClientResponse, {
69
+ export declare const executeOnCluster: (target: PveTarget, method: PveMethod, path: string, build: (apiBase: string) => HttpClientRequest.HttpClientRequest, timeout?: Duration.Input) => Effect.Effect<HttpClientResponse.HttpClientResponse, {
51
70
  readonly tag: 'cluster';
52
71
  readonly method: PveMethod;
53
72
  readonly path: string;
@@ -1 +1 @@
1
- {"version":3,"file":"members.d.ts","sourceRoot":"","sources":["../../src/proxmox/members.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAE9D,OAAO,KAAK,KAAK,iBAAiB,MAAM,wCAAwC,CAAC;AACjF,OAAO,KAAK,KAAK,kBAAkB,MAAM,yCAAyC,CAAC;AACnF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC,yFAAyF;AACzF,eAAO,MAAM,UAAU,WAAY,MAAM,WAA0D,CAAC;AAKpG,iEAAiE;AACjE,eAAO,MAAM,oBAAoB,YAAgC,CAAC;AAElE,eAAO,MAAM,cAAc,WAAY,SAAS,UAAU,MAAM,SAE/D,CAAC;AAEF,6DAA6D;AAC7D,eAAO,MAAM,cAAc,WAAY,SAAS,KAAG,SAAS,MAAM,EAIjE,CAAC;AA2CF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,UAAW,OAAO,KAAG,OAInD,CAAC;AAEF,qGAAqG;AACrG,eAAO,MAAM,kBAAkB,UAAW,OAAO,KAAG,OASnD,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAW,OAAO,KAAG,MAGlD,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAE1D;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,WACnB,SAAS,UACT,SAAS,QACX,MAAM,SACL,CAAC,OAAO,EAAE,MAAM,KAAK,iBAAiB,CAAC,iBAAiB,KAC9D,MAAM,CAAC,MAAM,CACd,kBAAkB,CAAC,kBAAkB,EACrC;IACE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;CAC7C,EACD,UAAU,CAAC,UAAU,CAmCnB,CAAC"}
1
+ {"version":3,"file":"members.d.ts","sourceRoot":"","sources":["../../src/proxmox/members.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAE9D,OAAO,KAAK,KAAK,iBAAiB,MAAM,wCAAwC,CAAC;AACjF,OAAO,KAAK,KAAK,kBAAkB,MAAM,yCAAyC,CAAC;AACnF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC,yFAAyF;AACzF,eAAO,MAAM,UAAU,WAAY,MAAM,WAA0D,CAAC;AAEpG;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,KAAoB,CAAC;AAK3D,iEAAiE;AACjE,eAAO,MAAM,oBAAoB,YAAgC,CAAC;AAElE,eAAO,MAAM,cAAc,WAAY,SAAS,UAAU,MAAM,SAE/D,CAAC;AAEF,6DAA6D;AAC7D,eAAO,MAAM,cAAc,WAAY,SAAS,KAAG,SAAS,MAAM,EAIjE,CAAC;AA2CF;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,UAAW,OAAO,KAAG,OAKnD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,UAAW,OAAO,KAAG,OASnD,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAW,OAAO,KAAG,MAIlD,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAE1D;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB,WACnB,SAAS,UACT,SAAS,QACX,MAAM,SACL,CAAC,OAAO,EAAE,MAAM,KAAK,iBAAiB,CAAC,iBAAiB,YACtD,QAAQ,CAAC,KAAK,KACtB,MAAM,CAAC,MAAM,CACd,kBAAkB,CAAC,kBAAkB,EACrC;IACE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;CAC7C,EACD,UAAU,CAAC,UAAU,CAqCnB,CAAC"}
@@ -13,7 +13,7 @@ The overview is [caddy.md](./caddy.md).
13
13
  - **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
14
14
  ITS port): pass `hostHeader: 'localhost:2019'`.
15
15
  - **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
16
- `localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
16
+ `localCaddyAdmin()` at it, or implement `CaddyTransport` over your own route. Never expose :2019.
17
17
  - **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
18
18
  if it turns the API off, listens off loopback or somewhere the transport does not reach (another
19
19
  port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
package/docs/caddy.md CHANGED
@@ -3,7 +3,8 @@
3
3
  Declare a running Caddy's config as Caddyfile text and apply it through Caddy's own admin API:
4
4
  `POST /load` to apply (a graceful reload), `GET /config/` to detect drift. `caddyWithFile()` also
5
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.
6
+ keeps it. Every admin call goes through `@distilled.cloud/caddy`'s typed operations, over one
7
+ injectable `CaddyTransport` (`Credentials` + `HttpClient.HttpClient`).
7
8
 
8
9
  ```ts
9
10
  import * as Effect from 'effect/Effect';
@@ -193,7 +194,7 @@ it by hand at its admin API.
193
194
  - ⚠️ **Bootstrap race.** Where Caddy is not running yet, the config can reach the admin API before
194
195
  its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
195
196
  `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
+ - ⚠️ One `CaddyTransport` per stack: the transport picks the Caddy, not a prop. Pointing it at another
197
198
  Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
198
199
  applied to, so the apply loads there only if it serves nothing, runs the declared config or the
199
200
  one last stored — anything else needs `--adopt`.
@@ -29,3 +29,19 @@ Framework `TokenAuthentication` scheme — NOT `Bearer`, measured against this e
29
29
  instance) is built by the package's protocol layer, not by this package. ⛔ The token is never a
30
30
  prop: Alchemy persists attributes unencrypted, so nothing stored in a stack file may be a
31
31
  credential.
32
+
33
+ The Paperless subpath (2026-09-24, moved onto `@distilled.cloud/paperless-ngx` — same route) also
34
+ resolves credentials through the SDK's own `CredentialsFromEnv`, reading `PAPERLESS_URL` /
35
+ `PAPERLESS_TOKEN` — the same two variable names the retired hand-rolled `credentials.ts` read.
36
+ `Authorization: Token <value>` (Paperless-ngx's own DRF `TokenAuthentication`, same scheme as
37
+ NetBox) is built by the package's protocol layer. Baked directly into each of the four
38
+ `xxxProvider()`s (`paperlessProviders()` no longer takes a credentials-layer override — see the
39
+ migration's changeset), the same way the Forgejo and NetBox subpaths above do.
40
+
41
+ The LiteLLM subpath (2026-09-24, moved onto `@distilled.cloud/litellm` — see
42
+ [distilled-interim.md](./distilled-interim.md)) resolves credentials the same way, through the
43
+ SDK's own `CredentialsFromEnv` layer, which reads `LITELLM_PROXY_URL` / `LITELLM_PROXY_API_KEY` —
44
+ the same two variable names the retired hand-rolled `credentials.ts` read (LiteLLM's own `litellm`
45
+ CLI client's variable names, not a house choice), still resolved on the calling fiber per request,
46
+ never captured at module scope. `Authorization: Bearer <value>` is built by the package's protocol
47
+ layer, not by this package. ⛔ The key is never a prop, for the same reason as above.
package/docs/litellm.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # LiteLLM — `@homeflare/alchemy/litellm`
2
2
 
3
3
  `LiteLLM.PassThroughEndpoint` declares one row of LiteLLM's `/config/pass_through_endpoint`
4
- family: a route on the proxy that forwards requests to an upstream target, generated from
5
- LiteLLM **1.100.0**'s own OpenAPI document (`codegen/manifest.json`'s `litellm-openapi` entry —
6
- vendor code at tag `v1.100.0`, dumped by the vendor's own `gen-api-types.mjs`, cross-checked
7
- byte-identically against the tag's committed `schema.d.ts`; NOT a live read, because the reference
8
- proxy's `/openapi.json` could not be reached when this was walked — see the manifest entry's note).
4
+ family: a route on the proxy that forwards requests to an upstream target. Every call goes
5
+ through `@distilled.cloud/litellm`'s typed `misc` operations (2026-09-24, moved off a hand-rolled
6
+ `client.ts`/`generated/pass-through.ts` the same way `/netbox` and `/forgejo` did —
7
+ [distilled-interim.md](./distilled-interim.md); not published upstream yet, so aliased onto
8
+ `@homeflare/distilled-litellm`). Its types are generated from LiteLLM **1.100.0**'s own OpenAPI
9
+ document (the distilled clone's own `packages/litellm/README.md` has the provenance: vendor code
10
+ at tag `v1.100.0`, dumped by the vendor's own `gen-api-types.mjs`, cross-checked byte-identically
11
+ against the tag's committed `schema.d.ts`; NOT a live read, because the reference proxy's
12
+ `/openapi.json` could not be reached when this was walked).
9
13
 
10
14
  ## What is DB-backed and API-managed, versus config-file only
11
15
 
@@ -21,11 +25,13 @@ Measured at 1.100.0 (`schema.prisma`, `pass_through_endpoints.py`, and the mini'
21
25
 
22
26
  ## Credentials
23
27
 
24
- Reads `LITELLM_PROXY_URL` and `LITELLM_PROXY_API_KEY` at call time — LiteLLM's own variable
25
- names, the ones its `litellm` CLI reads (`litellm/proxy/client/cli/main.py` at the tag). No
26
- default URL. The key must be LiteLLM's master key or another `PROXY_ADMIN` key:
27
- `update_config_general_settings`, the function every mutation goes through, is `PROXY_ADMIN`
28
- only. Never a prop — see [docs/credentials.md](./credentials.md) for the house rule.
28
+ Reads `LITELLM_PROXY_URL` and `LITELLM_PROXY_API_KEY` at call time, through
29
+ `@distilled.cloud/litellm`'s own `CredentialsFromEnv` — the same two variable names LiteLLM's own
30
+ `litellm` CLI reads (`litellm/proxy/client/cli/main.py` at the tag), unchanged by the move off the
31
+ retired hand-rolled `credentials.ts`. No default URL. The key must be LiteLLM's master key or
32
+ another `PROXY_ADMIN` key: `update_config_general_settings`, the function every mutation goes
33
+ through, is `PROXY_ADMIN` only. Never a prop — see [docs/credentials.md](./credentials.md) for the
34
+ house rule.
29
35
 
30
36
  ## Refusals
31
37
 
@@ -59,9 +65,11 @@ two rows on the same path, which this resource never allows.
59
65
  ## The whole-field write, and why every mutation is serialised
60
66
 
61
67
  Every pass-through endpoint lives in ONE `general_settings.pass_through_endpoints` list. Create,
62
- update and delete are each a read-modify-write of that WHOLE list. `client.ts` wraps every
63
- mutating call in a per-base-URL `Effect` semaphore, so two of THIS PROCESS's own calls never
64
- race each other — two endpoints declared in the same deploy both survive.
68
+ update and delete are each a read-modify-write of that WHOLE list. `operations.ts` wraps every
69
+ mutating call — now the SDK's typed operations, not a hand-rolled client — in a per-base-URL
70
+ `Effect` semaphore, so two of THIS PROCESS's own calls never race each other — two endpoints
71
+ declared in the same deploy both survive. The SDK has no opinion on this: it is a quirk of how
72
+ THIS vendor stores the resource, not a distilled protocol concern.
65
73
 
66
74
  ⛔ **What the semaphore does not cover.** It serialises this package's own writes within one
67
75
  process. It cannot serialise against the LiteLLM UI, another stack, or a second concurrent
package/docs/paperless.md CHANGED
@@ -4,6 +4,18 @@ Four resources — `Paperless.Tag`, `Paperless.DocumentType`, `Paperless.Storage
4
4
  `Paperless.CustomField` — the taxonomy Paperless-ngx writes through Django's internal models,
5
5
  create-or-update and never deleted by default. Workflows and mail accounts are a later slice.
6
6
 
7
+ ## Moved onto `@distilled.cloud/paperless-ngx` (2026-09-24)
8
+
9
+ 🔴 Every wire call in this family now goes through `@distilled.cloud/paperless-ngx`'s typed
10
+ operations (`tags.createTag`, `documentTypes.listDocumentTypes`, …) with
11
+ `Effect.catchTag('NotFound', …)` at each resource file's own `getById`, the same route
12
+ `forgejo/*` and `netbox/*` took — see [distilled-interim.md](./distilled-interim.md) and this
13
+ migration's changeset for the full story. `client.ts`, `errors.ts` and `credentials.ts` are gone.
14
+ The rest of this document — locate-by-name-then-id (PR 163), `owner: null` on create, the
15
+ create-only `dataType`, `retain` by default — is the family's OWN behaviour, unchanged by the
16
+ transport swap and proved unchanged by `tag.test.ts`/`tag-identity.test.ts` against a fake that
17
+ speaks the real distilled protocol.
18
+
7
19
  ## Credentials
8
20
 
9
21
  Two environment variables, read **at call time**, never props:
package/docs/peers.md CHANGED
@@ -64,3 +64,8 @@ automatically for every consumer that installs `@homeflare/alchemy` — nothing
64
64
  peer install line, and nothing for `peers.test.ts` to check. When the real package ships
65
65
  upstream, the alias's target changes (or the entry becomes a normal peer, matching
66
66
  `@distilled.cloud/forgejo` above) and this line still does not change for it.
67
+
68
+ ★ **`@distilled.cloud/litellm` (added 2026-09-24, moving `/litellm` off a hand-rolled
69
+ `HttpClient` client the same way `/netbox` did) is the same shape**: aliased onto
70
+ `@homeflare/distilled-litellm@0.2.0` as a plain `dependencies` entry, not a peer — nothing to
71
+ add here, nothing for `peers.test.ts` to check.
@@ -18,7 +18,22 @@ Effect.gen(function* () {
18
18
  });
19
19
  ```
20
20
 
21
- Provide `GitHub.providers()` **and** `RepositoryRulesetProvider()` from this subpath.
21
+ Provide `repoPolicyProviders()` from this subpath as the stack's `providers` — the composed
22
+ `GitHub.providers()` + `RepositoryRulesetProvider()` layer:
23
+
24
+ ```ts
25
+ import { repoPolicyProviders } from '@homeflare/alchemy/github';
26
+
27
+ // providers: repoPolicyProviders()
28
+ ```
29
+
30
+ ⛔ **Writing the two providers side by side does not typecheck** (measured 2026-09-24,
31
+ homeflare-builds bump PR 6): `RepositoryRulesetProvider()`'s handlers need `GitHubCredentials`,
32
+ and merging layers does not thread one's output into another's requirement.
33
+ `repoPolicyProviders()` feeds `GitHub.providers()` into `RepositoryRulesetProvider()` with
34
+ `Layer.provideMerge` so the composed layer needs nothing further — see
35
+ [repository-ruleset.md](./repository-ruleset.md#providers) for the full account.
36
+
22
37
  ⚠️ **Rewired 2026-09-23**: the ruleset half now composes this house's own
23
38
  `GitHub.RepositoryRuleset` (see [repository-ruleset.md](./repository-ruleset.md)), not
24
39
  upstream `GitHub.Ruleset` — every hazard [below](#-the-ruleset-half-has-hazards-of-its-own)
@@ -30,10 +30,44 @@ Effect.gen(function* () {
30
30
  });
31
31
  ```
32
32
 
33
- Provide **both** `GitHub.providers()` (for `GitHub.Repository`, if declared alongside) and
34
- `RepositoryRulesetProvider()` — this is a separate `Provider`, registered under its own
35
- type string (`GitHub.RepositoryRuleset`), not inside upstream's `GitHub.Providers`
36
- collection. The two coexist in one stack without any change to upstream's own code.
33
+ This is a separate `Provider`, registered under its own type string
34
+ (`GitHub.RepositoryRuleset`), not inside upstream's `GitHub.Providers` collection — the two
35
+ coexist in one stack without any change to upstream's own code.
36
+
37
+ ## Providers
38
+
39
+ Provide `repoPolicyProviders()` (`@homeflare/alchemy/github`) as the stack's `providers`, not
40
+ `GitHub.providers()` and `RepositoryRulesetProvider()` written side by side:
41
+
42
+ ```ts
43
+ import { repoPolicyProviders } from '@homeflare/alchemy/github';
44
+
45
+ // providers: repoPolicyProviders()
46
+ ```
47
+
48
+ ⛔ **The side-by-side form does not typecheck** — measured 2026-09-24 (homeflare-builds bump PR
49
+ 6, tsc TS2345), two separate bugs stacked on top of each other:
50
+
51
+ 1. **The layer** still requires `GitHubCredentials` when the two providers are merged plainly:
52
+ `RepositoryRulesetProvider()`'s handlers call `octokitFor`, which needs it, and merging two
53
+ layers does not thread one's output into the other's requirement. `repoPolicyProviders()`
54
+ feeds `GitHub.providers()` into `RepositoryRulesetProvider()` with `Layer.provideMerge`
55
+ instead of a plain merge. `GitHub.providers()` resolves credentials internally and — because
56
+ it chains `provideMerge`, not `provide`, onto its own auth layer — re-exposes
57
+ `GitHubCredentials` in its own output too, so this both satisfies the requirement and keeps
58
+ `GitHub.Providers` available for any `GitHub.Repository`/`GitHub.Ruleset` declared alongside.
59
+ 2. **The declaration** failed to typecheck on its own, even with the layer fixed, whenever a
60
+ stack body called `RepositoryRuleset` — directly, or via
61
+ `declareRepoPolicy`/`declareRepoBaseline`. `RepositoryRuleset`'s `Resource<>` declaration put
62
+ `GitHubCredentials` in the 5th (`Providers`) type parameter, which puts the credential
63
+ requirement on the DECLARATION itself, not just the provider. A stack body cannot supply
64
+ `GitHubCredentials` this way — `Alchemy.Stack`'s own `ProviderServices` type is a closed
65
+ union that `GitHubCredentials` does not structurally match (see repository-ruleset.ts's file
66
+ header for the full trace against `alchemy/src/Resource.ts` and `alchemy/src/Stack.ts`).
67
+ Upstream's own `GitHub.Ruleset` never makes this mistake: its 5th slot is `GitHub.Providers`
68
+ (the collection tag), never the raw credential service. `RepositoryRuleset` now omits the
69
+ 5th parameter, defaulting its declaration requirement to `Provider<RepositoryRuleset>`
70
+ instead.
37
71
 
38
72
  ## Type string: H14
39
73
 
@@ -55,12 +89,12 @@ exported credential service.
55
89
 
56
90
  ## What it models beyond upstream `RulesetProps`
57
91
 
58
- | field | upstream `Ruleset` | this resource |
59
- | ------------------------------------------------- | ------------------ | ------------------ |
60
- | `pullRequest.allowedMergeMethods` | no | yes |
61
- | `pullRequest.requiredReviewers` | no | yes |
62
- | `pullRequest.extraApprovalForUnattributedChanges` | no | `false` only (H15) |
63
- | `requiredStatusChecks.doNotEnforceOnCreate` | no | yes |
92
+ | field | upstream `Ruleset` | this resource |
93
+ | ------------------------------------------------- | ------------------ | ------------------------ |
94
+ | `pullRequest.allowedMergeMethods` | no | yes |
95
+ | `pullRequest.requiredReviewers` | no | yes |
96
+ | `pullRequest.extraApprovalForUnattributedChanges` | no | `true`/`false` (K1, H15) |
97
+ | `requiredStatusChecks.doNotEnforceOnCreate` | no | yes |
64
98
 
65
99
  `RULE_TYPE_COVERAGE` in `repository-ruleset-constraints.ts` classifies every rule type
66
100
  Octokit's own installed types know (21 of them — see the file for the 27.0.0-vs-29.0.1
@@ -79,9 +113,11 @@ being silently dropped by the wholesale `rules` PUT.
79
113
  see `undeclaredLiveRuleRefusal`'s own comment for the concrete scenario (forgetting
80
114
  `deletion: true` would otherwise silently drop branch-deletion protection) that made
81
115
  this the general case rather than a special one.
82
- - **Adding a required check that has never reported success** on the default branch's
83
- current tip (checks + statuses): refused before any write. Documented limitation: this
84
- checks the current tip only, not full history (repository-ruleset-octokit.ts).
116
+ - **Adding a required check that has never reported success**: refused before any write.
117
+ Checked at the default branch's current tip, then (K2) up to `RECENT_MERGED_PR_LIMIT`
118
+ recent merged PR heads — bounded, not full history (repository-ruleset-octokit.ts). The
119
+ fallback exists because the rendered CI runs on `pull_request` only, so `ci`/`secret
120
+ scan`/`CodeQL` never report on the tip itself.
85
121
  - **A readback mismatch**: after any write, an INDEPENDENT `GET` — never the write's own
86
122
  response — is compared against what was sent for the approval count, merge methods and
87
123
  the extension flag. A mismatch fails the plan.
@@ -110,5 +146,9 @@ caller, the same as upstream `GitHub.Ruleset`.
110
146
  what a create sends, zero writes on a noop or a refusal, exactly one update on real
111
147
  drift, and the readback check.
112
148
  - `repository-ruleset-wire.test.ts` — the H15 measurement against a real Octokit instance.
149
+ - `repository-ruleset-octokit.test.ts` — the K2 measurement, same real-Octokit-with-fetch-
150
+ shim pattern as the wire test: a context reporting only on a recent merged PR head is
151
+ accepted, one reporting nowhere (tip or any recent merged head) is still refused, and
152
+ one reporting only past `RECENT_MERGED_PR_LIMIT` stays refused too (the bound is real).
113
153
  - `repository-ruleset-coverage.test.ts` — the rule-type coverage change detector (the
114
154
  completeness guarantee itself is enforced by `tsc`, not this file — see its header).
@@ -69,17 +69,73 @@ then tidiness.
69
69
  (S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
70
70
  under Node, which is the runtime this package promises its consumers. The `node:*` paths
71
71
  load on Node but break the Effect-only rule.
72
- 5. **`launchd`, `linux` and `caddy` have promise-based seams (H3).** `HostRunner` and
73
- `CaddyAdmin` are plain `async` interfaces, with `node:child_process`, `node:fs/promises`
74
- and `node:http` behind them, and failures are untyped `Error` (S19, S21). The stated
75
- reason is that a consumer implements plain async functions. Upstream meets the same need
76
- by letting a caller provide `FileSystem`, `ChildProcessSpawner` or `CommandExecutor`
77
- layers, and `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
72
+ 5. **`launchd` and `linux` still have promise-based seams (H3).** `HostRunner` is a plain
73
+ `async` interface, with `node:child_process` and `node:fs/promises` behind it, and
74
+ failures are untyped `Error` (S19, S21). The stated reason is that a consumer implements
75
+ plain async functions. Upstream meets the same need by letting a caller provide
76
+ `FileSystem`, `ChildProcessSpawner` or `CommandExecutor` layers, and
77
+ `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
78
78
  ⚠️ Only in part. `local-runner.ts` creates its temp file with `O_EXCL`, mode 0600 and
79
79
  `chown` before `chmod`, and it refuses a planted symlink. `writeFileAtomic` writes with
80
80
  default flags and mode, `chmod`s afterwards, and never `chown`s.
81
81
  **Decision:** maintainer. These families are also host-specific, so "house-only" is a
82
82
  valid answer.
83
+
84
+ ✅ **S23 fixed 2026-09-24** (branch `claude2/distilled-caddy-migrate`): `caddy/*` no
85
+ longer has a promise-based seam at all. `Caddy.Config` now calls
86
+ `@distilled.cloud/caddy`'s typed `admin` operations (`adaptConfig`, `loadConfig`,
87
+ `getConfig`), `catchTag`-able via `Caddy.CaddyOpError`, with `isUnreachable`
88
+ (caddy-http-client.ts) replacing the old `CaddyUnreachableError` class as a type guard
89
+ over the SDK's own `HttpClientError`. The old `CaddyAdmin`/`CaddyAdminRequest`/
90
+ `CaddyAdminResponse`/`CaddyAdminError` interfaces and `admin-calls.ts`'s hand-rolled
91
+ `POST /adapt`/`POST /load`/`GET /config/` calls are gone. `local-admin.ts`'s measured
92
+ unix-socket-plus-loopback transport (Host/Origin headers, ECONNREFUSED/ENOENT-only
93
+ retry) survives as caddy-http-client.ts, now built as an Effect `HttpClient.HttpClient`
94
+ the SDK's own protocol runs over, instead of a raw `node:http` promise interface — still
95
+ `node:http`, not `FetchHttpClient`, because only `node:http` dials a unix socket on both
96
+ Bun and Node (`FetchHttpClient`'s `unix` option is Bun-only). The `LoadRefused` 200-trap
97
+ (a refused `/load` that still answers 200) is now first-class in the SDK's own
98
+ `protocol.ts`, not re-detected in this package. A genuine SDK gap this migration found
99
+ and patched in the distilled clone (never in this resource): `AdaptConfig`/`LoadConfig`'s
100
+ `config` field is Caddyfile TEXT under a caller-chosen `Content-Type`, which
101
+ `buildRequest`'s generic `HttpBody()` cascade has no seam for (no static `bodyMediaType`
102
+ for a dynamically-typed body) — it fell to the JSON-body default, which both
103
+ `JSON.stringify`d the Caddyfile text and overwrote the caller's `Content-Type` with
104
+ `application/json`. Fixed in `protocol.ts`'s `encode`, shipped as
105
+ `@homeflare/distilled-caddy@0.2.1` (a patch changeset). A second, unrelated finding: the
106
+ SDK's own default `Caddy.Retry` policy treats ANY `HttpClientError` with a
107
+ `TransportError` reason as retryable — including an ECONNRESET well after a `POST /load`
108
+ was accepted — which would have re-sent an already-accepted load past the point
109
+ admin-calls.ts's original module doc says never to. `local-admin.ts` now disables it
110
+ (`Layer.succeed(Caddy.Retry.Retry, { while: () => false })`), leaving
111
+ caddy-http-client.ts's own narrower retry (ECONNREFUSED/ENOENT only) as the one retry
112
+ policy in play. State did not move: props and attributes stay byte-identical, proven by
113
+ the family's existing tests (updated only where the typed-error message text itself
114
+ changed) plus fake-caddy.ts, a real HTTP server, so every test already drives the real
115
+ distilled wire protocol.
116
+ ⛔ **Alchemy trap found along the way, house-wide, not caddy-specific:** a
117
+ `Provider.effect(...)` layer built with `SomeLayer.pipe(Layer.provide(depsLayer))` seals
118
+ `depsLayer`'s services away from the provider's OWN `read`/`diff`/`reconcile` handlers
119
+ when THEY run later — `Layer.provide` satisfies the provider's construction-time
120
+ requirement and then hides it, so a handler that itself does `yield* SomeService` (as
121
+ `Provider.effect`'s own `ReadReq`/`DiffReq`/`ReconcileReq` type parameters invite)
122
+ dies with "Service not found" the moment the engine calls it, not when the layer is
123
+ built. `Layer.provideMerge` is the fix — it feeds the dependency's output into the
124
+ provider's requirement AND keeps that output live in the result. `providers.ts`'s
125
+ `caddyProviders()` and this family's tests were the ones actually broken by it, but any
126
+ family whose provider handlers read a service from context has the same exposure.
127
+
128
+ **File-size note, added in the `claude2/distilled-caddy-followup` review pass:** the
129
+ `encode()` fix above grew `packages/distilled-caddy/src/protocol.ts` from 243 to 294
130
+ lines, past the house's 250-line code-file cap — the original PR didn't flag this. Left
131
+ as-is on purpose, not split: `distilled-interim.md` treats all of
132
+ `packages/distilled-*/src/**` as copied-not-edited vendor code (⛔ "src/ is copied,
133
+ never hand-edited in the kit"; already exempt from `.oxfmtrc.json`/`.oxlintrc.json` for
134
+ the identical reason), which is the AGENTS.md line-cap rule's own carve-out for
135
+ "vendored code." Its source of truth is branch `homeflare/caddy` in the distilled clone
136
+ — a split belongs there first, then gets copied forward on the next regeneration, not
137
+ decided unilaterally in this kit copy.
138
+
83
139
  6. **`openbao/*` has no upstream equivalent, and it conforms on the contract** (Effect
84
140
  `HttpClient`, strict `Unowned`, retain). It diverges in three ways:
85
141
  - 30 `Effect.die` sites in non-test source, several of them on `diff` and
@@ -121,18 +177,69 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
121
177
  - ⛔ **Still diverges on credentials (S24):** `NETBOX_TOKEN` / `NETBOX_URL` are read at
122
178
  call time, now through the SDK's `CredentialsFromEnv` rather than a hand-rolled
123
179
  `token()` — same divergence, unchanged by the transport swap.
124
- 10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
180
+ 10. **`litellm/*` has no upstream equivalent** (LiteLLM has no upstream Alchemy family, the
181
+ same situation `openbao/*` is in above), and conforms on the transport contract.
182
+ - ✅ **S23 fixed 2026-09-24** (branch `claude2/litellm-distilled`, mirroring the NetBox
183
+ migration above, decision 43). `LiteLLM.PassThroughEndpoint` now calls
184
+ `@distilled.cloud/litellm`'s typed `misc` operations (`operations.ts`) instead of a
185
+ hand-rolled `Effect HttpClient` client. The old status-carrying `LitellmBadRequestError`
186
+ family is gone: every failure the four operations declare (`BadRequest`, `NotFound` on
187
+ update, `UnprocessableEntity`, plus the shared `Unauthorized`/`TooManyRequests`/server
188
+ errors) is the SDK's own typed error, `catchTag`'d — `deletePassThroughEndpoint`'s
189
+ re-list-on-ambiguous-`BadRequest` trick (S21: a status-and-a-real-read decision, never
190
+ body text) is unchanged, now keyed on the tag instead of the status code. The
191
+ per-base-URL write semaphore this vendor's whole-list storage forces (docs/litellm.md)
192
+ is unchanged too — the SDK has no opinion on it, so it stays one layer above the typed
193
+ calls, in `operations.ts`. `client.ts` and the kit's own hand-generated
194
+ `generated/pass-through.ts` are both deleted (the SDK's `misc.PassThroughGenericEndpoint`
195
+ is the same shape, generated from the same LiteLLM 1.100.0 OpenAPI document — see
196
+ `codegen/manifest.json`'s `litellm-openapi` entry, left in place and unconsumed rather
197
+ than deleted, the same pattern the two UniFi entries already establish). Not published
198
+ upstream yet, so aliased onto `@homeflare/distilled-litellm@0.2.0` as a plain
199
+ `dependencies` entry, not a peer — [distilled-interim.md](./distilled-interim.md). State
200
+ did not move: props/attributes stay byte-identical, proven by the family's unchanged
201
+ existing tests (now against a fake LiteLLM exercising the real distilled protocol
202
+ through `FetchHttpClient.Fetch`, not a loopback `Bun.serve`) plus the engine-level
203
+ replace test through `fake-stack.ts`. No stack in this estate currently imports
204
+ `litellmProviders`/`LiteLLM.PassThroughEndpoint` (measured 2026-09-24 across
205
+ homeflare-landscape's own repositories, including this one's `stacks/`), so there is no
206
+ live plan to re-run.
207
+ - ⛔ **Still diverges on credentials (S24):** `LITELLM_PROXY_URL` / `LITELLM_PROXY_API_KEY`
208
+ are read at call time, now through the SDK's `CredentialsFromEnv` rather than a
209
+ hand-rolled `resolveCreds` — same divergence, unchanged by the transport swap.
210
+ - ✅ **S20 fixed 2026-09-24** (same branch, PR review pass): `CredentialsFromEnv` ended in
211
+ `Effect.orDie`, so a missing/misspelled env var died as an engine-crashing defect
212
+ instead of the typed `ConfigError` `LitellmOpError` already declared — a regression
213
+ this migration made newly reachable (the retired hand-rolled `credentials.ts` failed
214
+ typed). Fixed at the source: `@distilled.cloud/litellm`'s own `credentials.ts`
215
+ (`homeflare/litellm@4ad19154`, commit local, not pushed — S22), copied forward here.
216
+ `netbox/*`'s `CredentialsFromEnv` has the identical `orDie` shape and is unfixed —
217
+ out of scope for this item; tracked as its own follow-up (task `task_3bbe1684`).
218
+ 11. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
125
219
  `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
126
220
  (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
127
221
  where nuke must never reach the object.
128
- 11. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
222
+ 12. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
129
223
  `**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
130
224
  the house's glyph rationale into `//` comments.
131
- 12. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
225
+ 13. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
132
226
  fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
133
227
 
134
228
  ## Conforms
135
229
 
230
+ - **`paperless/*` (`Tag`, `DocumentType`, `StoragePath`, `CustomField`)** — ✅ **S23 fixed
231
+ 2026-09-24** (branch `claude2/distilled-paperless-family`, decision 43, mirroring the
232
+ netbox/forgejo migrations above). Every call now goes through `@distilled.cloud/paperless-ngx`'s
233
+ typed operations, `catchTag('NotFound', …)` replaced the status-carrying `PaperlessError`
234
+ union, and `client.ts`/`errors.ts`/`credentials.ts` are deleted. The shared
235
+ `matching.ts`/`matching-locate.ts` engine (locate-by-name before state, by `output.id` after —
236
+ PR 163) is unchanged in shape. Not published upstream yet, aliased onto
237
+ `@homeflare/distilled-paperless-ngx@0.3.0` — [distilled-interim.md](./distilled-interim.md).
238
+ State did not move: props/attributes stay byte-identical, proven by `tag.test.ts` and the full
239
+ PR 163 regression suite (`tag-identity.test.ts`) against a fake exercising the real distilled
240
+ protocol. ⛔ **Still diverges on credentials (S24):** `PAPERLESS_URL`/`PAPERLESS_TOKEN` are read
241
+ at call time, now through the SDK's `CredentialsFromEnv` — same divergence, unchanged by the
242
+ transport swap.
136
243
  - **`github/RepositoryRuleset`** (added 2026-09-23) closes the hazard the row above names:
137
244
  probes by name (`read`/`reconcile` both), answers `Unowned` on a cold name match (H1),
138
245
  normalizes before comparing so a matching live ruleset is a true noop, and refuses
@@ -196,7 +303,7 @@ among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a b
196
303
  | `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
197
304
  | `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
198
305
  | `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
199
- | `caddy/local-admin.ts` | `node:http` (unix socket) | yes | H3 above |
306
+ | `caddy/caddy-http-client.ts` | `node:http`, `node:stream` (unix socket) | yes | inherent — an Effect `HttpClient`, not H3 ⚠️ |
200
307
  | `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
201
308
  | `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
202
309
 
@@ -209,6 +316,15 @@ The ⚠️ rows:
209
316
  - `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
210
317
  replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
211
318
  state, and it would change the persisted seal format.
319
+ - `caddy/caddy-http-client.ts`: unlike `launchd/local-runner.ts` and `linux/ssh-runner.ts`
320
+ (H3, plain `async` interfaces), this is already the Effect `HttpClient.HttpClient` the
321
+ distilled SDK's own protocol runs on — `node:http` here is the same kind of unavoidable,
322
+ bottom-of-the-stack platform adapter `@effect/platform-node`'s own `NodeHttpClient.ts` is,
323
+ extended with `socketPath` dialing (that module has none) because a unix socket is the
324
+ measured transport a local Caddy needs and neither `FetchHttpClient` (Bun-only `unix`
325
+ option) nor stock `NodeHttpClient` (URL-only) speaks one on both Bun and Node. There is no
326
+ more-portable replacement to point to; the file already conforms to S19 (no async/await,
327
+ no raw `Promise` — `Effect.callback`/`Effect.tryPromise` throughout).
212
328
 
213
329
  Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
214
330
  talos 1), and 126 import `bun:test`. S43 says `bun:test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
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",
@@ -94,7 +94,7 @@
94
94
  "access": "public"
95
95
  },
96
96
  "dependencies": {
97
- "@distilled.cloud/caddy": "npm:@homeflare/distilled-caddy@0.2.0",
97
+ "@distilled.cloud/caddy": "npm:@homeflare/distilled-caddy@0.2.2",
98
98
  "@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
99
99
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
100
100
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
@@ -103,7 +103,10 @@
103
103
  },
104
104
  "peerDependencies": {
105
105
  "@distilled.cloud/cloudflare": "1.0.0-rc.12",
106
+ "@distilled.cloud/discord": "1.0.0-rc.12",
106
107
  "@distilled.cloud/forgejo": "1.0.0-rc.12",
108
+ "@distilled.cloud/google-workspace": "1.0.0-rc.12",
109
+ "@distilled.cloud/grafana": "1.0.0-rc.12",
107
110
  "@effect/platform-node": "4.0.0-rc.115",
108
111
  "@effect/sql-pg": "4.0.0-rc.115",
109
112
  "alchemy": "2.0.0-beta.79",
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Parses a `localCaddyAdmin()` address into a dial target and the listener Caddy checks requests
3
+ * against — pure, so admin-guard.ts and local-admin.ts's tests exercise it with no transport at all.
4
+ *
5
+ * ★ LOOPBACK OR A UNIX SOCKET, NOTHING ELSE. The admin API is unauthenticated plaintext; an address
6
+ * off this machine means it is reachable from the network, which decision 23 forbids. Reaching
7
+ * another host's Caddy means forwarding its socket or port to here (SSH), never exposing :2019.
8
+ * ★ HOST, AS CADDY's OWN CLI SENDS IT (cmd/commandfuncs.go AdminAPIRequest): `host:port`, over TCP.
9
+ * MEASURED against a Mac host's Caddy 2.11.4: any other Host gets 403 `host not allowed`, a
10
+ * forwarded port included — hence `hostHeader`.
11
+ */
12
+ import type { CaddyAdminListener } from './admin.ts';
13
+
14
+ export type Target =
15
+ | { readonly kind: 'tcp'; readonly host: string; readonly port: number }
16
+ | { readonly kind: 'unix'; readonly socketPath: string };
17
+
18
+ export const DEFAULT_ADMIN_ADDRESS = 'http://127.0.0.1:2019';
19
+
20
+ const UNIX = 'unix://';
21
+ const HOST_PORT = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}$/;
22
+
23
+ const refuse = (address: string, why: string): Error =>
24
+ new Error(`Caddy admin address ${JSON.stringify(address)}: ${why}`);
25
+
26
+ const isLoopback = (hostname: string): boolean =>
27
+ hostname === 'localhost' || hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(hostname);
28
+
29
+ /** The dial target and listener for an address, or a refusal naming what is wrong with it. */
30
+ export const parseAdminAddress = (
31
+ address: string,
32
+ hostHeader?: string,
33
+ ): { target: Target; listener: CaddyAdminListener } => {
34
+ if (hostHeader !== undefined && !HOST_PORT.test(hostHeader)) {
35
+ throw refuse(address, `hostHeader ${JSON.stringify(hostHeader)} must be host:port`);
36
+ }
37
+ if (address.startsWith(UNIX)) {
38
+ const socketPath = address.slice(UNIX.length);
39
+ if (!socketPath.startsWith('/')) throw refuse(address, 'the socket path must be absolute');
40
+ return { listener: { kind: 'unix', path: socketPath }, target: { kind: 'unix', socketPath } };
41
+ }
42
+ let url: URL;
43
+ try {
44
+ url = new URL(address);
45
+ } catch {
46
+ throw refuse(address, 'not a URL (http://127.0.0.1:2019 or unix:///path)');
47
+ }
48
+ // ⛔ Caddy's local admin endpoint is plaintext by design; its TLS one (`admin.remote`, mTLS on
49
+ // :2021) is a different, network-facing API this transport deliberately does not speak.
50
+ if (url.protocol !== 'http:') throw refuse(address, 'only http:// (loopback) or unix://');
51
+ if (!isLoopback(url.hostname)) {
52
+ throw refuse(address, 'not loopback — the admin API is unauthenticated; forward it to here');
53
+ }
54
+ if (url.pathname !== '/' || url.search !== '' || url.username !== '') {
55
+ throw refuse(address, 'no path, query or credentials — the API lives at the root');
56
+ }
57
+ const port = url.port === '' ? 80 : Number(url.port);
58
+ // ⚠️ NOT `url.host`: WHATWG URL drops a default port (`http://127.0.0.1:80` has host
59
+ // `127.0.0.1`), and Caddy compares the Host against `host:port` (admin.go allowedOrigins), so
60
+ // a portless Host is a 403 on every call. The Caddy CLI always sends the port (JoinHostPort).
61
+ const host = hostHeader ?? `${url.hostname}:${String(port)}`;
62
+ return {
63
+ listener: {
64
+ hostHeader: host,
65
+ kind: 'tcp',
66
+ port: Number(host.slice(host.lastIndexOf(':') + 1)),
67
+ },
68
+ // ★ `hostname` keeps IPv6 brackets; node:http wants them off.
69
+ target: { host: url.hostname.replace(/^\[(.*)\]$/, '$1'), kind: 'tcp', port },
70
+ };
71
+ };