@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
@@ -6,35 +6,50 @@
6
6
  * {path, user|group|token, role} exists. That is what makes those four families worth declaring,
7
7
  * and why the grants that let OpenBao mint a PVE token today are only clicks somebody made once.
8
8
  *
9
- * ⛔ THE ONE FAMILY THAT DOES NOT FIT `pveOperations` AS WRITTEN, AND THE MISFIT IS MEASURED. The
9
+ * ⛔ THE ONE FAMILY THAT DOES NOT FIT A GENERIC PATH+FORM SHAPE, AND THE MISFIT IS MEASURED. The
10
10
  * cluster's own schema — `/pve-docs/api-viewer/apidoc.js`, read unauthenticated 2026-09-13 —
11
11
  * lists exactly TWO methods on `/access/acl`: GET and PUT. There is no DELETE (it answers
12
- * "Method 'DELETE /access/acl' not implemented") and, less famously, NO POST either. So create
13
- * and update are the SAME call, `PUT /access/acl`, and removal is that PUT with `delete=1` —
14
- * which is why `delete` below does not use `ops.destroy`.
15
- * ⚠️ THE REAL FIX BELONGS IN `resource.ts`: give `PveSpec` a `deleteForm` and the method to go
16
- * with it, and this override disappears. Until then it is local and loud — do not "restore
17
- * symmetry" by pointing `delete` at `ops.destroy`, which reports every removal as a 501 while
18
- * the grant stays exactly where it was.
12
+ * "Method 'DELETE /access/acl' not implemented") and, less famously, NO POST either — which is
13
+ * why `@distilled.cloud/proxmox`'s generator, reading the same schema, emits `listAccessAcl` and
14
+ * `putAccessAcl` and NOTHING ELSE for this path. So create and update are the SAME call, `PUT
15
+ * /access/acl`, and removal is that PUT with `delete=1` — there is no `destroyAccessAcl` to call.
19
16
  *
20
17
  * ⛔ A SINGLE GRANT HAS NO URL OF ITS OWN. `GET /access/acl` answers ONE FLAT LIST for the whole
21
- * cluster, so identity is the tuple (path, type, ugid, roleid), matched client-side in
22
- * `attributes` below. Two resources declaring the SAME tuple are the SAME grant — Alchemy sees
23
- * two resource ids, not one collision — and deleting either takes the access away from both,
24
- * the same hazard as two guests declaring one vmid.
18
+ * cluster, so identity is the tuple (path, type, ugid, roleid), matched client-side in `find`
19
+ * below. Two resources declaring the SAME tuple are the SAME grant — Alchemy sees two resource
20
+ * ids, not one collision — and deleting either takes the access away from both, the same hazard
21
+ * as two guests declaring one vmid.
25
22
  *
26
23
  * ⛔ AND THAT LIST IS FILTERED BY WHO IS ASKING. The schema's own words for GET: "The returned list
27
24
  * is restricted to objects where you have rights to modify permissions." A credential that can
28
25
  * read the cluster but not modify permissions is answered `[]` — not a 403, not an error — so
29
26
  * the mount's `read` role needs permission-modify rights on the declared path exactly as
30
- * `provision` does. Work reported on a grant that is plainly there means that role is too narrow.
27
+ * `provision` does (below, `listAcl` reads with the `provision` role for this reason). Work
28
+ * reported on a grant that is plainly there means that role is too narrow.
29
+ *
30
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
31
+ * `access.listAccessAcl`/`access.putAccessAcl` (2026-09-23, decision 43's proxmox walk-down,
32
+ * the sub-area's first resource — ACL was already the misfit `pveOperations` could not cover
33
+ * cleanly, which is exactly why it goes first). `distilled-pve.ts`'s `runPve` replaces `pve()`:
34
+ * same lease reuse (lease-cache.ts), same cluster-member failover (members.ts), now handing the
35
+ * request itself to the SDK instead of a hand-assembled `HttpClientRequest`.
36
+ * ⛔ ONE DEAD BRANCH REMOVED, NOT PRESERVED: the old code's generic factory POSTed on a "live
37
+ * undefined" read, which its own header called unreachable on a healthy cluster and
38
+ * documented as MISLEADING — PVE has no POST here, so that 501 said nothing about the real
39
+ * failure. `@distilled.cloud/proxmox` has no `createAccessAcl` at all (the vendor schema has
40
+ * no POST to generate one from), so there is nothing to call that way. `reconcile` below
41
+ * always PUTs instead — the only write PVE actually implements for this path — so a read that
42
+ * genuinely failed now surfaces its REAL cause (a typed error, or `PveClusterExhausted`)
43
+ * rather than a confusing 501 from a verb that was never there.
31
44
  */
32
45
  import { Resource } from 'alchemy';
33
46
  import { isResolved } from 'alchemy/Diff';
34
47
  import * as Provider from 'alchemy/Provider';
48
+ import * as access from '@distilled.cloud/proxmox/access';
35
49
  import * as Effect from 'effect/Effect';
36
- import { pve } from './client.ts';
37
- import { type PveRequirements, type WithTarget, pveOperations } from './resource.ts';
50
+ import { guardForm } from './constraint-guard.ts';
51
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
52
+ import { runPve } from './distilled-pve.ts';
38
53
 
39
54
  /** PVE's three kinds of subject. The read answers this word; the write wants its plural. */
40
55
  export type AclSubjectType = 'user' | 'group' | 'token';
@@ -42,8 +57,8 @@ export type AclSubjectType = 'user' | 'group' | 'token';
42
57
  export interface AclProps extends WithTarget {
43
58
  /**
44
59
  * The PVE object path the grant is ON — `/`, `/pool/lab`, `/vms/101`, `/storage/local-zfs`.
45
- * ⚠️ NOT THE API PATH: `spec.path` below is the endpoint (`access/acl`). PVE's API overloads the
46
- * word, so this file does too rather than renaming a field the cluster calls `path`.
60
+ * ⚠️ NOT THE API PATH — the endpoint is fixed (`access/acl`). PVE's API overloads the word,
61
+ * so this file does too rather than renaming a field the cluster calls `path`.
47
62
  */
48
63
  path: string;
49
64
  /** Which kind of subject `ugid` names. Identity: changing it is a different grant. */
@@ -62,13 +77,7 @@ export interface AclAttributes {
62
77
  ugid: string;
63
78
  roleid: string;
64
79
  propagate: boolean;
65
- /**
66
- * ⛔ PRESENCE AS AN ATTRIBUTE, AND IT IS WHAT MAKES THE FACTORY WORK HERE. `attributes` never
67
- * returns undefined: if it did, `reconcile` would take the create branch and POST to
68
- * `access/acl`, which PVE does not implement. Present-but-unbound sends every write down the
69
- * PUT branch instead, the only branch PVE has. The cost: the factory's read-back guard cannot
70
- * fire, so `reconcile` below re-checks this field itself and dies with the same honesty.
71
- */
80
+ /** Presence, so a PUT-only family can tell "matches" from "does not exist yet" — see below. */
72
81
  bound: boolean;
73
82
  }
74
83
 
@@ -82,163 +91,134 @@ export interface ProxmoxAcl extends Resource<
82
91
 
83
92
  export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
84
93
 
85
- /**
86
- * ⚠️ PVE NORMALISES ACL PATHS AND RETURNS THE NORMALISED FORM, a forever-update trap when only one
87
- * side of the comparison is normalised. `PVE::AccessControl::normalize_path` collapses repeated
88
- * slashes and strips the trailing one, so a declared `/pool/lab/` reads back as `/pool/lab`
89
- * and a naive match never fires again. It also refuses a path with no leading slash, so one is
90
- * added here rather than letting `pool/lab` 400.
91
- */
94
+ /** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
92
95
  const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
93
96
 
94
- /**
95
- * ⚠️ THE WRITE NAMES THE SUBJECT WITH A PLURAL KEY THAT DIFFERS PER KIND — `users`, `groups`,
96
- * `tokens` — while the READ answers a singular `type`/`ugid` pair. The PUT declares
97
- * `additionalProperties: 0`, so `user=` fails with a 400 rather than being ignored. The bad case
98
- * is the WRONG plural: a valid parameter naming a subject kind you did not mean.
99
- */
97
+ /** The write names the subject with a plural key that differs per kind; the read answers singular. */
100
98
  const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
101
99
 
102
- /** The tuple PVE keys a grant by, as the form it takes. Every write to this family starts here. */
103
- const tuple = (props: AclProps): Record<string, string> => ({
100
+ /** The vendor endpoint every write and every constraint check runs against. */
101
+ const ENDPOINT = 'pve:PUT /access/acl';
102
+
103
+ /** The tuple PVE keys a grant by, as PUT's fields. */
104
+ const tuple = (props: AclProps): access.PutAccessAclRequest => ({
104
105
  path: normalize(props.path),
105
106
  roles: props.roleid,
106
107
  [SUBJECT_FIELD[props.type]]: props.ugid,
107
108
  });
108
109
 
109
110
  /** Create and update are one call: the tuple plus the single mutable field. */
110
- const bind = (props: AclProps): Record<string, string> => ({
111
- propagate: props.propagate === false ? '0' : '1',
111
+ const bind = (props: AclProps): access.PutAccessAclRequest => ({
112
112
  ...tuple(props),
113
+ propagate: props.propagate === false ? '0' : '1',
113
114
  });
114
115
 
115
- /** What a change of this string means: not an edit, a different grant. Used by `diff` below. */
116
- const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
117
- [normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
116
+ /** `guardForm` wants a plain string-valued form; distilled's request type carries `?` instead. */
117
+ const asForm = (body: access.PutAccessAclRequest): Record<string, string> =>
118
+ Object.fromEntries(Object.entries(body).filter((e): e is [string, string] => e[1] !== undefined));
118
119
 
119
120
  /**
120
- * ⚠️ `GET /access/acl` ANSWERS AN ARRAY, while the factory hands `attributes` the
121
- * `Record<string, unknown>` every other PVE read is shaped like. Rows are narrowed, not trusted,
122
- * and an empty result is not evidence of an empty cluster — see the last ⛔ in the header.
121
+ * ⚠️ ALWAYS PRESENCE-CHECKED. `bind`'s output IS the create form (there is no separate,
122
+ * narrower update form for this family — see the header), so the vendor's required-parameter
123
+ * check is never wrong to run; the two separate create/update passes the generic factory ran
124
+ * elsewhere collapse to one call here without losing coverage.
123
125
  */
124
- const find = (live: unknown, props: AclProps) =>
125
- (Array.isArray(live) ? live : [])
126
- .filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null)
127
- .find(
128
- (row) =>
129
- row['path'] === normalize(props.path) &&
130
- row['type'] === props.type &&
131
- row['ugid'] === props.ugid &&
132
- row['roleid'] === props.roleid,
133
- );
126
+ const guardWrite = (props: AclProps) => guardForm(ENDPOINT, asForm(bind(props)), true);
127
+
128
+ /** What a change of this string means: not an edit, a different grant. */
129
+ const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
130
+ [normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
131
+
132
+ const find = (rows: readonly access.ListAccessAclResponseBodyItem[], props: AclProps) =>
133
+ rows.find(
134
+ (row) =>
135
+ row.path === normalize(props.path) &&
136
+ row.type === props.type &&
137
+ row.ugid === props.ugid &&
138
+ row.roleid === props.roleid,
139
+ );
134
140
 
135
141
  /**
136
- * ⚠️ PVE ANSWERS `propagate` AS 1/0 RATHER THAN true/false, AND OMITS IT WHEN IT CARRIES THE API
137
- * DEFAULT, WHICH IS ON. Reading an absent field as `false` would report drift on every plan,
138
- * forever, for every grant this provider did not create itself.
142
+ * ⚠️ PVE answers `propagate` as 1/0 rather than true/false, and OMITS it when it carries the API
143
+ * default, which is ON. distilled's generated schema types it `unknown` for the same reason.
139
144
  */
140
- const propagates = (row: Record<string, unknown>) => {
141
- const value = row['propagate'];
145
+ const propagates = (row: access.ListAccessAclResponseBodyItem) => {
146
+ const value = row.propagate;
142
147
  return value === undefined || value === 1 || value === true || value === '1';
143
148
  };
144
149
 
145
- const ops = pveOperations<AclProps, AclAttributes>({
146
- attributes: (live, props) => {
147
- const row = find(live, props);
148
- return {
149
- // ⚠️ `propagate` reads false when nothing is bound, because there is no grant to inherit.
150
- // `bound` is the field that carries presence; this one means something only once it is set.
151
- bound: row !== undefined,
152
- path: normalize(props.path),
153
- propagate: row !== undefined && propagates(row),
154
- roleid: props.roleid,
155
- type: props.type,
156
- ugid: props.ugid,
157
- };
158
- },
159
- /**
160
- * ⛔ THE CREATE PATH IS UNREACHABLE ON A HEALTHY CLUSTER, AND ITS ERROR WILL MISLEAD YOU. The
161
- * factory POSTs `collection` only when the read came back undefined, and `attributes` never
162
- * does — so a POST means the GET itself failed (an expired 300s lease, a node down), not that
163
- * the grant is missing. PVE answers "Method 'POST /access/acl' not implemented": read that 501
164
- * as "the read failed" and go and look at the credential, not at the ACL.
165
- */
166
- /**
167
- * ⛔ `provision`, AND WITHOUT IT EVERY GRANT READS BACK AS ABSENT. `GET /access/acl` is
168
- * FILTERED, NOT GATED — the schema's own words are "The returned list is restricted to objects
169
- * where you have rights to modify permissions". A credential without Permissions.Modify gets
170
- * HTTP 200 and an EMPTY ARRAY, never a 403, so there is no error for `read` to fold; it simply
171
- * sees nothing.
172
- * ⛔ THE READ LEASE IS EXACTLY SUCH A CREDENTIAL. `hf-read@pve` is PVEAuditor on `/`, whose
173
- * privilege set is Datastore.Audit, Mapping.Audit, Pool.Audit, SDN.Audit, Sys.Audit,
174
- * VM.Audit, VM.GuestAgent.Audit — MEASURED in the 2026-09-13 cluster read. No
175
- * Permissions.Modify. So on the default lease all fourteen live grants read back unbound,
176
- * `matches` is false for every one, the plan reports fourteen updates for grants that
177
- * plainly exist, and the deploy PUTs each one before acl.ts's own read-back guard dies.
178
- * ★ THIS IS THE SAME CLASS AS storage.ts, sdn-zone.ts, sdn-vnet.ts AND api-token.ts, and it is
179
- * the worst instance of it: those three are gated and answer 403, which is at least an
180
- * error. A filtered endpoint answers success with less data, which is indistinguishable from
181
- * the object not being there.
182
- */
183
- readRole: 'provision',
184
- collection: () => 'access/acl',
185
- createForm: bind,
186
- /**
187
- * ⚠️ ONLY `propagate` IS COMPARED, AND ONLY ONCE THE GRANT IS THERE. path/type/ugid/roleid are
188
- * the FILTER that produced these attributes, not a reading of the cluster: comparing them with
189
- * the props they came from is true by construction. An identity change is a DIFFERENT grant,
190
- * which `diff` in the provider handles.
191
- */
192
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
193
- endpoint: { create: 'pve:PUT /access/acl', update: 'pve:PUT /access/acl' },
194
- matches: (attributes, props) =>
195
- attributes.bound && attributes.propagate === (props.propagate !== false),
196
- path: () => 'access/acl',
197
- updateForm: bind,
198
- });
150
+ const attributesOf = (
151
+ rows: readonly access.ListAccessAclResponseBodyItem[],
152
+ props: AclProps,
153
+ ): AclAttributes => {
154
+ const row = find(rows, props);
155
+ return {
156
+ bound: row !== undefined,
157
+ path: normalize(props.path),
158
+ propagate: row !== undefined && propagates(row),
159
+ roleid: props.roleid,
160
+ type: props.type,
161
+ ugid: props.ugid,
162
+ };
163
+ };
164
+
165
+ const matches = (attributes: AclAttributes, props: AclProps) =>
166
+ attributes.bound && attributes.propagate === (props.propagate !== false);
167
+
168
+ /**
169
+ * ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
170
+ * ⛔ on filtering. `read`/`orElseSucceed` folds ANY failure (network, a genuine permission
171
+ * error) into "unknown"; a 200 with an empty or non-matching list is a real answer, not folded.
172
+ */
173
+ const readAttributes = (props: AclProps) =>
174
+ runPve(props.target, 'provision', false, access.listAccessAcl({})).pipe(
175
+ Effect.map((rows) => attributesOf(rows, props)),
176
+ Effect.orElseSucceed(() => undefined),
177
+ );
199
178
 
200
179
  export const ProxmoxAclProvider = () =>
201
180
  Provider.effect(
202
181
  ProxmoxAcl,
203
182
  Effect.succeed(
204
183
  ProxmoxAcl.Provider.of({
205
- /**
206
- * ⛔ EMPTY LIKE EVERY OTHER RESOURCE HERE, AND MOST OF ALL THIS ONE. `GET /access/acl`
207
- * hands back every grant a human ever clicked; adopting them would let a later plan
208
- * DELETE somebody's access as tidy-up. Adoption is an explicit act.
209
- */
184
+ /** ⛔ EMPTY LIKE EVERY OTHER PVE RESOURCE. Adopting every grant a human ever clicked is not adoption. */
210
185
  list: () => Effect.succeed([]),
211
186
  read: Effect.fn(function* ({ olds }) {
212
- return yield* ops.read(olds);
187
+ return yield* readAttributes(olds);
213
188
  }),
214
189
  /**
215
- * ⛔ AN IDENTITY CHANGE IS A REPLACE, AND THE FACTORY CANNOT SAY SO ALONE — it answers
216
- * `replace` only for objects with no update path, and this one has PUT. Left to delegate,
217
- * editing `roleid` (or the subject, or the path) would PUT the NEW grant and LEAVE THE
218
- * OLD ONE BOUND: access nobody declared, held indefinitely, invisible in the plan because
219
- * the provider believes it converged. Replace makes Alchemy call `delete` with the OLD
220
- * props, which is the only thing that removes it.
221
- * ⚠️ CREATE-FIRST, DELIBERATELY (Alchemy's default; `deleteFirst: true` would invert it).
222
- * Delete-first could remove the very grant the provision credential mints against and
223
- * leave nothing able to put it back. The cost is that a NARROWING change leaves the
224
- * wider grant bound until Phase 2 collects the old generation.
190
+ * ⛔ AN IDENTITY CHANGE IS A REPLACE. Editing `roleid` (or the subject, or the path) would
191
+ * PUT the NEW grant and LEAVE THE OLD ONE BOUND; replace makes Alchemy `delete` the old
192
+ * props first, the only thing that removes it. Create-first (Alchemy's default) is
193
+ * deliberate: delete-first could remove the very grant the provision credential mints
194
+ * against.
225
195
  */
226
196
  diff: Effect.fn(function* ({ news, output }) {
227
197
  if (output !== undefined && isResolved(news) && identity(news) !== identity(output)) {
228
198
  return { action: 'replace' } as const;
229
199
  }
230
- return yield* ops.diff(news, output);
200
+ if (!isResolved(news)) return undefined;
201
+ yield* guardWrite(news);
202
+ if (output === undefined) return undefined;
203
+ const live = yield* readAttributes(news);
204
+ return live !== undefined && matches(live, news)
205
+ ? ({ action: 'noop' } as const)
206
+ : ({ action: 'update' } as const);
231
207
  }),
232
208
  /**
233
- * ⚠️ THE READ-BACK GUARD, RESTORED FOR A FAMILY WHOSE "ABSENT" IS NOT `undefined`.
234
- * `ops.reconcile` refuses when the object is still missing after a write, but missing
235
- * here is `bound: false`, which that check cannot see. PVE answers 200 with
236
- * `{"data":null}` on calls that did nothing: without this, a PUT that silently no-oped
237
- * is recorded as a landed grant and the next plan reads noop over the gap.
209
+ * ⚠️ THE READ-BACK GUARD, FOR A FAMILY WHOSE "ABSENT" IS `bound: false`, NOT `undefined`.
210
+ * PVE answers 200 with `{"data":null}` on calls that did nothing: without this, a PUT
211
+ * that silently no-oped is recorded as a landed grant and the next plan reads noop over
212
+ * the gap.
238
213
  */
239
214
  reconcile: Effect.fn(function* ({ news }) {
240
- const after = yield* ops.reconcile(news);
241
- if (!after.bound) {
215
+ const before = yield* readAttributes(news);
216
+ yield* guardWrite(news);
217
+ if (before === undefined || !matches(before, news)) {
218
+ yield* runPve(news.target, 'provision', true, access.putAccessAcl(bind(news)));
219
+ }
220
+ const after = yield* readAttributes(news);
221
+ if (after === undefined || !after.bound) {
242
222
  return yield* Effect.die(
243
223
  new Error(
244
224
  `access/acl: the write returned no error but ${identity(news)} is still not ` +
@@ -251,18 +231,16 @@ export const ProxmoxAclProvider = () =>
251
231
  return after;
252
232
  }),
253
233
  /**
254
- * ⛔ NOT `ops.destroy`, AND NOT AN OVERSIGHT. `DELETE /access/acl` is not implemented, so a
255
- * factory delete would fail every destroy — while looking like a permissions problem —
256
- * and leave the grant bound. Removal is the create call with `delete=1` and the identity
257
- * tuple, no `propagate`, because removal is keyed on the tuple and nothing else.
258
- *
259
- * ⚠️ IDEMPOTENT, AND SHARED: removing a grant that is already gone is a no-op, but the
260
- * tuple is cluster-global — re-read the second ⛔ in the header before assuming this
261
- * only takes away what this stack declared.
234
+ * ⛔ NOT A DESTROY CALL — THERE IS NONE. `DELETE /access/acl` is not implemented; removal
235
+ * is the same PUT with `delete=1` and the identity tuple, no `propagate`, because
236
+ * removal is keyed on the tuple and nothing else.
237
+ * ⚠️ IDEMPOTENT, AND CLUSTER-WIDE: removing a grant that is already gone is a no-op, but
238
+ * the tuple is shared — re-read the header's second ⛔ before assuming this only takes
239
+ * away what this stack declared.
262
240
  */
263
241
  delete: Effect.fn(function* ({ olds }) {
264
- const removal = { delete: '1', ...tuple(olds) };
265
- yield* pve(olds.target, 'provision', 'PUT', 'access/acl', removal);
242
+ const removal: access.PutAccessAclRequest = { ...tuple(olds), delete: '1' };
243
+ yield* runPve(olds.target, 'provision', true, access.putAccessAcl(removal));
266
244
  }),
267
245
  }),
268
246
  ),
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Cluster member failover for `runPveWith`/`runPve`, over the REAL `@distilled.cloud/proxmox`
3
+ * protocol (real path assembly, real form encoding, real `{"data":...}` unwrap) instead of a
4
+ * hand-built request — the distilled-backed counterpart to client.test.ts, same servers, same
5
+ * rule, same helper shape. members.test.ts still owns the pre-send/any-transport classification
6
+ * itself; this file only proves `runPveWith` obeys it when the request comes from the SDK.
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { describe, it } from 'node:test';
10
+ import * as access from '@distilled.cloud/proxmox/access';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
14
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
15
+ import type { PveCredential, PveTarget } from './credentials.ts';
16
+ import { PveClusterExhausted, runPveWith } from './distilled-pve.ts';
17
+ import { orderedMembers, resetLastGoodForTest } from './members.ts';
18
+
19
+ type ServeOptions = {
20
+ fetch(request: Request): Response | Promise<Response>;
21
+ hostname: string;
22
+ port: number;
23
+ };
24
+ const { Bun } = globalThis as unknown as {
25
+ Bun: { serve(options: ServeOptions): { port: number; stop(closeActive: boolean): void } };
26
+ };
27
+
28
+ const CRED: PveCredential = {
29
+ leaseSeconds: 300,
30
+ secret: 'not-a-real-secret',
31
+ tokenId: 'hf-read@pve!fake',
32
+ };
33
+
34
+ const target = (members: readonly string[]): PveTarget => ({
35
+ members,
36
+ mount: 'proxmox-c1-test',
37
+ scheme: 'pve',
38
+ });
39
+
40
+ /** Same remap as client.test.ts: https://member:8006 -> http://127.0.0.1:<port>. Never a live host. */
41
+ const remapFetch = (ports: Record<string, number>, underlying: typeof fetch = globalThis.fetch) =>
42
+ Object.assign(
43
+ (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
44
+ const href =
45
+ typeof input === 'string'
46
+ ? input
47
+ : input instanceof URL
48
+ ? input.href
49
+ : input instanceof Request
50
+ ? input.url
51
+ : String(input);
52
+ const url = new URL(href);
53
+ const port = ports[url.hostname];
54
+ if (port !== undefined) {
55
+ url.protocol = 'http:';
56
+ url.hostname = '127.0.0.1';
57
+ url.port = String(port);
58
+ return underlying(url, init);
59
+ }
60
+ return Promise.reject(
61
+ Object.assign(new Error(`getaddrinfo ENOTFOUND ${url.hostname}`), { code: 'ENOTFOUND' }),
62
+ );
63
+ },
64
+ { preconnect: globalThis.fetch.preconnect },
65
+ ) as typeof fetch;
66
+
67
+ const layerFor = (ports: Record<string, number>) =>
68
+ FetchHttpClient.layer.pipe(
69
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, remapFetch(ports))),
70
+ );
71
+
72
+ const run = <A, E>(
73
+ effect: Effect.Effect<A, E, HttpClient.HttpClient>,
74
+ ports: Record<string, number> = {},
75
+ ) => Effect.runPromise(effect.pipe(Effect.provide(layerFor(ports))));
76
+
77
+ /** A fake member answering PVE's own `{"data": ...}` envelope, counting its requests. */
78
+ const counting = (data: unknown, status = 200) => {
79
+ let hits = 0;
80
+ const server = Bun.serve({
81
+ fetch: () => {
82
+ hits += 1;
83
+ return Response.json({ data }, { status });
84
+ },
85
+ hostname: '127.0.0.1',
86
+ port: 0,
87
+ });
88
+ return { hits: () => hits, port: server.port, stop: () => server.stop(true) };
89
+ };
90
+
91
+ const A = 'pve-a.test';
92
+ const B = 'pve-b.test';
93
+ const readAcl = access.listAccessAcl({});
94
+ const writeAcl = access.putAccessAcl({ path: '/', roles: 'PVEAuditor', users: 'a@pve' });
95
+
96
+ describe('runPveWith: cluster member failover over the distilled protocol', () => {
97
+ it('skips an unresolvable first member and answers from the second', async () => {
98
+ resetLastGoodForTest();
99
+ const up = counting([{ path: '/', roleid: 'PVEAuditor', type: 'user', ugid: 'a@pve' }]);
100
+ try {
101
+ const rows = await run(runPveWith(target([A, B]), CRED, false, readAcl), { [B]: up.port });
102
+ assert.equal(rows.length, 1);
103
+ } finally {
104
+ up.stop();
105
+ }
106
+ });
107
+
108
+ it('does not fail over on an HTTP answer (a typed error) from the first member', async () => {
109
+ resetLastGoodForTest();
110
+ const bad = Bun.serve({
111
+ fetch: () =>
112
+ Response.json({ data: null, message: 'Permission check failed' }, { status: 403 }),
113
+ hostname: '127.0.0.1',
114
+ port: 0,
115
+ });
116
+ const good = counting([]);
117
+ try {
118
+ const error = await run(runPveWith(target([A, B]), CRED, false, readAcl).pipe(Effect.flip), {
119
+ [A]: bad.port,
120
+ [B]: good.port,
121
+ });
122
+ assert.equal((error as { _tag?: string })._tag, 'Forbidden');
123
+ assert.equal(good.hits(), 0);
124
+ } finally {
125
+ bad.stop(true);
126
+ good.stop();
127
+ }
128
+ });
129
+
130
+ it('tries the last-good member first on the next call', async () => {
131
+ resetLastGoodForTest();
132
+ const serverA = counting([]);
133
+ const serverB = counting([]);
134
+ try {
135
+ const t = target([A, B]);
136
+ const ports = { [A]: serverA.port, [B]: serverB.port };
137
+ await run(runPveWith(t, CRED, false, readAcl), ports);
138
+ assert.equal(serverA.hits(), 1);
139
+ assert.equal(serverB.hits(), 0);
140
+ assert.deepEqual(orderedMembers(t), [A, B]);
141
+ await run(runPveWith(t, CRED, false, access.listAccessAcl({})), ports);
142
+ assert.equal(serverA.hits(), 2);
143
+ assert.equal(serverB.hits(), 0);
144
+ } finally {
145
+ serverA.stop();
146
+ serverB.stop();
147
+ }
148
+ });
149
+
150
+ it('does not re-send a write after a transport timeout', async () => {
151
+ resetLastGoodForTest();
152
+ const hang = Bun.serve({
153
+ fetch: () => new Promise<Response>(() => {}),
154
+ hostname: '127.0.0.1',
155
+ port: 0,
156
+ });
157
+ const backup = counting({});
158
+ try {
159
+ await run(
160
+ runPveWith(target([A, B]), CRED, true, writeAcl).pipe(
161
+ Effect.timeout('50 millis'),
162
+ Effect.flip,
163
+ ),
164
+ { [A]: hang.port, [B]: backup.port },
165
+ );
166
+ // ⛔ B must never receive the PUT — a post-send timeout is not pre-send failover.
167
+ assert.equal(backup.hits(), 0);
168
+ } finally {
169
+ hang.stop(true);
170
+ backup.stop();
171
+ }
172
+ });
173
+
174
+ it('fails a write over when the first member refuses the connection', async () => {
175
+ resetLastGoodForTest();
176
+ const backup = counting({});
177
+ try {
178
+ // Loopback port 1 is closed: Bun answers ConnectionRefused before a byte is written.
179
+ await run(runPveWith(target([A, B]), CRED, true, writeAcl), { [A]: 1, [B]: backup.port });
180
+ assert.equal(backup.hits(), 1);
181
+ } finally {
182
+ backup.stop();
183
+ }
184
+ });
185
+
186
+ it('names every member when all are down', async () => {
187
+ resetLastGoodForTest();
188
+ const error = await run(runPveWith(target([A, B]), CRED, false, readAcl).pipe(Effect.flip));
189
+ assert.ok(error instanceof PveClusterExhausted);
190
+ assert.match(error.message, /all members failed/);
191
+ assert.match(error.message, new RegExp(A));
192
+ assert.match(error.message, new RegExp(B));
193
+ });
194
+
195
+ /**
196
+ * ⛔ UNLIKE THE 'transport timeout' TEST ABOVE, NO OUTER `Effect.timeout` HERE. That test's outer
197
+ * 50ms wrapper wins the race regardless of whether `runPveWith` bounds an attempt itself — it
198
+ * would still pass with no internal bound at all. These inject the bound INTO `runPveWith`
199
+ * (never done in production, which always uses the real `MEMBER_TIMEOUT`), so a regression
200
+ * hangs node:test's own per-test timeout instead of quietly passing. See members-timeout.test.ts
201
+ * for the members.ts-level twin.
202
+ * ⚠️ EXPLICIT `{ timeout: 15000 }`: bun's default per-test kill is 5000ms (MEASURED), too close
203
+ * to this shared, often-loaded dev machine's own jitter to trust for a 100ms-bounded call.
204
+ */
205
+ it(
206
+ 'a hung read fails over to the next member, within the injected bound alone',
207
+ { timeout: 15000 },
208
+ async () => {
209
+ resetLastGoodForTest();
210
+ const hang = Bun.serve({
211
+ fetch: () => new Promise<Response>(() => {}),
212
+ hostname: '127.0.0.1',
213
+ port: 0,
214
+ });
215
+ const good = counting([{ path: '/', roleid: 'PVEAuditor', type: 'user', ugid: 'a@pve' }]);
216
+ try {
217
+ const started = Date.now();
218
+ const rows = await run(runPveWith(target([A, B]), CRED, false, readAcl, '100 millis'), {
219
+ [A]: hang.port,
220
+ [B]: good.port,
221
+ });
222
+ assert.equal(rows.length, 1);
223
+ assert.equal(good.hits(), 1);
224
+ assert.ok(Date.now() - started < 10000);
225
+ } finally {
226
+ hang.stop(true);
227
+ good.stop();
228
+ }
229
+ },
230
+ );
231
+ });