@homeflare/alchemy 0.31.0 → 0.31.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 (123) hide show
  1. package/dist/proxmox/acl-wire.d.ts +26 -0
  2. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  3. package/dist/proxmox/acl.d.ts.map +1 -1
  4. package/dist/proxmox/api-token-form.d.ts +28 -26
  5. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  6. package/dist/proxmox/api-token.d.ts +48 -89
  7. package/dist/proxmox/api-token.d.ts.map +1 -1
  8. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  9. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  10. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  11. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  12. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  13. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  14. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  15. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  16. package/dist/proxmox/client.d.ts +2 -2
  17. package/dist/proxmox/client.d.ts.map +1 -1
  18. package/dist/proxmox/credential-errors.d.ts +27 -0
  19. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  20. package/dist/proxmox/credentials.d.ts +0 -38
  21. package/dist/proxmox/credentials.d.ts.map +1 -1
  22. package/dist/proxmox/distilled-guard.d.ts +31 -0
  23. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  24. package/dist/proxmox/distilled-pve.d.ts +11 -4
  25. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  26. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  27. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  28. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  29. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  44. package/dist/proxmox/group-wire.d.ts +29 -0
  45. package/dist/proxmox/group-wire.d.ts.map +1 -0
  46. package/dist/proxmox/group.d.ts +20 -24
  47. package/dist/proxmox/group.d.ts.map +1 -1
  48. package/dist/proxmox/index.js +725 -509
  49. package/dist/proxmox/index.js.map +40 -33
  50. package/dist/proxmox/lease-cache.d.ts +1 -1
  51. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  52. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  53. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  54. package/dist/proxmox/mint.d.ts +50 -0
  55. package/dist/proxmox/mint.d.ts.map +1 -0
  56. package/dist/proxmox/network-apply-read.d.ts +2 -2
  57. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  59. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  60. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  61. package/dist/proxmox/resource.d.ts +4 -4
  62. package/dist/proxmox/role-wire.d.ts +28 -0
  63. package/dist/proxmox/role-wire.d.ts.map +1 -0
  64. package/dist/proxmox/role.d.ts +36 -52
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  67. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  68. package/dist/proxmox/unreadable-read.d.ts +50 -0
  69. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  70. package/dist/proxmox/user-wire.d.ts +18 -0
  71. package/dist/proxmox/user-wire.d.ts.map +1 -0
  72. package/dist/proxmox/user.d.ts +25 -32
  73. package/dist/proxmox/user.d.ts.map +1 -1
  74. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  75. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  76. package/package.json +1 -1
  77. package/src/proxmox/acl-wire.ts +83 -0
  78. package/src/proxmox/acl.ts +29 -82
  79. package/src/proxmox/adopt-noop.test.ts +18 -0
  80. package/src/proxmox/api-token-adopt.test.ts +28 -12
  81. package/src/proxmox/api-token-form.ts +50 -93
  82. package/src/proxmox/api-token.ts +149 -135
  83. package/src/proxmox/client.ts +2 -7
  84. package/src/proxmox/constraints-forms.test.ts +7 -27
  85. package/src/proxmox/credential-errors.ts +33 -0
  86. package/src/proxmox/credentials.test.ts +29 -3
  87. package/src/proxmox/credentials.ts +4 -134
  88. package/src/proxmox/distilled-guard.ts +42 -0
  89. package/src/proxmox/distilled-pve.ts +15 -4
  90. package/src/proxmox/generated/constraints/index.ts +1 -1
  91. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  92. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  93. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  94. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  95. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  96. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  97. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  98. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  107. package/src/proxmox/group-wire.ts +63 -0
  108. package/src/proxmox/group.test.ts +110 -0
  109. package/src/proxmox/group.ts +135 -105
  110. package/src/proxmox/lease-cache.ts +2 -1
  111. package/src/proxmox/lxc-lifecycle.ts +2 -1
  112. package/src/proxmox/mint.ts +158 -0
  113. package/src/proxmox/network-apply.ts +1 -1
  114. package/src/proxmox/provision-cli-fake.ts +11 -0
  115. package/src/proxmox/provision-declare.test.ts +6 -1
  116. package/src/proxmox/role-wire.ts +49 -0
  117. package/src/proxmox/role.test.ts +89 -0
  118. package/src/proxmox/role.ts +147 -106
  119. package/src/proxmox/unreadable-read.test.ts +83 -0
  120. package/src/proxmox/unreadable-read.ts +60 -0
  121. package/src/proxmox/user-wire.ts +112 -0
  122. package/src/proxmox/user.test.ts +142 -0
  123. package/src/proxmox/user.ts +132 -129
@@ -22,8 +22,9 @@
22
22
  * "users":"hf-provision@pve,hf-read@pve"}, …]
23
23
  * GET /access/groups/hf-mint -> {"comment":"…","members":["hf-provision@pve","hf-read@pve"]}
24
24
  * The index says `users` and hands back a COMMA STRING; the item says `members` and hands back an
25
- * ARRAY. A reader that looked for `users` on the item path would find nothing, report an empty
26
- * group, and quietly show a delete as harmless.
25
+ * ARRAY. distilled's generator reflects the SAME split (`GetAccessGroupResponse.members: string[]`
26
+ * vs `ListAccessGroupsResponseBodyItem.users?: string`), so reading the item is what keeps this
27
+ * file on the array shape it already normalises.
27
28
  *
28
29
  * ⛔ AND THE ITEM'S ORDER IS NOT STABLE BETWEEN TWO CONSECUTIVE CALLS. MEASURED, seconds apart, on
29
30
  * the same endpoint:
@@ -38,37 +39,56 @@
38
39
  * exactly `comment` and `members`, so the id comes from props — the same arrangement, and the
39
40
  * same reason, as `user.ts`.
40
41
  *
41
- * ★ PRIVILEGES: NOTHING HAD TO BE WIDENED, WHICH MAKES THIS THE FIRST FAMILY HERE THAT COST
42
- * NOTHING. Measured with `pveum user permissions`, 2026-09-13:
42
+ * ★ PRIVILEGES: NOTHING HAD TO BE WIDENED. Measured with `pveum user permissions`, 2026-09-13:
43
43
  * · item GET checks `['perm','/access/groups',['Sys.Audit','Group.Allocate'], any => 1]`, and
44
- * `hf-read@pve` already holds `Sys.Audit` there (propagated from `/` by `PVEAuditor`).
45
- * So `readRole` is left at the default `read` — unlike storage/sdn-zone/sdn-vnet, whose ITEM
46
- * reads PVE gates on an allocate privilege. See the ⛔ on `readRole` in `resource.ts`.
44
+ * `hf-read@pve` already holds `Sys.Audit` there. So `readAttributes` below reads with the
45
+ * default `read` role — unlike acl.ts, user.ts and api-token.ts, whose item/list reads PVE
46
+ * gates on an allocate privilege the 3600s read lease does not hold.
47
47
  * · POST, PUT and DELETE each check `Group.Allocate` on `/access/groups`, and
48
48
  * `hf-provision@pve` holds it via the provision role (`PROVISION_PRIVILEGES`).
49
49
  *
50
50
  * ⛔ `retain` BY DEFAULT, BECAUSE DELETING A GROUP DESTROYS TWO THINGS THIS FILE CANNOT PUT BACK.
51
51
  * Measured in `PVE::AccessControl` on the node:
52
- * · The member list IS the group. `user.cfg` stores it on the GROUP line (`group:<id>:<users>:
53
- * <comment>:`) and DERIVES each user's `groups` map from it at parse time. Deleting the group
54
- * deletes the list, and this resource has no `members` prop to restore it from.
52
+ * · The member list IS the group. `user.cfg` stores it on the GROUP line and DERIVES each
53
+ * user's `groups` map from it at parse time. Deleting the group deletes the list, and this
54
+ * resource has no `members` prop to restore it from.
55
55
  * · `delete_group_acl` then walks the whole ACL tree and drops every grant where the group is
56
56
  * the SUBJECT. On this cluster that is `admins -> Administrator on /` and
57
57
  * `automation -> PVEAuditor on /`: whole populations of access, gone in one call, with no
58
58
  * confirmation and nothing in the plan to suggest it.
59
- * `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists, unlike the ACL
60
- * family's — so `.pipe(RemovalPolicy.destroy())` really removes the group. See the ★ in
61
- * `resource.ts` for why the policy is a guard rather than a stubbed-out operation.
59
+ * `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists — so
60
+ * `.pipe(RemovalPolicy.destroy())` really removes the group.
62
61
  *
63
- * ⛔ NO SECRET REACHES STATE. A group holds a comment and a list of userids; there is no password,
64
- * no token and no key anywhere on these endpoints. `user.ts`'s ⛔ about unencrypted state applies
65
- * to this package, and this family simply has nothing to trip it.
62
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` ONTO `@distilled.cloud/proxmox`'s
63
+ * typed `access.getAccessGroup`/`createAccessGroup`/`putAccessGroup`/`deleteAccessGroup`
64
+ * (2026-09-24, decision 43's proxmox walk-down, access sub-area, second resource after Acl).
65
+ * `distilled-pve.ts`'s `runPve` replaces `pve()` as in acl.ts; the CRIES-WOLF FIX
66
+ * (unreadable-read.ts) is wired the same way: a refused `provision` mint on the WRITE-side
67
+ * guard, or a refused `read` mint on the plain read, reports `noop` with a warning rather than
68
+ * forcing `update` with nothing compared.
66
69
  */
67
70
  import { Resource } from 'alchemy';
71
+ import { isResolved } from 'alchemy/Diff';
68
72
  import * as Provider from 'alchemy/Provider';
73
+ import * as access from '@distilled.cloud/proxmox/access';
69
74
  import * as Effect from 'effect/Effect';
70
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
71
- import { csv, text } from './values.ts';
75
+ import { guardWrite } from './distilled-guard.ts';
76
+ import {
77
+ GROUP_CREATE,
78
+ GROUP_UPDATE,
79
+ attributesOf,
80
+ createForm,
81
+ matches,
82
+ updateForm,
83
+ } from './group-wire.ts';
84
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
85
+ import { runPve } from './distilled-pve.ts';
86
+ import {
87
+ UNREADABLE,
88
+ type Unreadable,
89
+ readOrUnreadable,
90
+ unreadableWarning,
91
+ } from './unreadable-read.ts';
72
92
 
73
93
  export interface GroupProps extends WithTarget {
74
94
  /**
@@ -108,94 +128,104 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
108
128
  });
109
129
 
110
130
  /**
111
- * The comment PVE will actually END UP HOLDING for a given declaration.
112
- *
113
- * ⛔ PVE CANNOT STORE THE COMMENT `'0'`, AND A PROVIDER THAT DOES NOT MODEL THAT DIFFS FOREVER.
114
- * Three places in the shipped Perl test the comment for TRUTH rather than for definedness, and
115
- * in Perl the one-character string `'0'` is false:
116
- * `create_group`: `$group->{comment} = $param->{comment} if $param->{comment};`
117
- * `write_user_config`: `my $comment = $d->{comment} ? encode_text($d->{comment}) : '';`
118
- * the config parser: `$cfg->{groups}->{$g}->{comment} = decode_text($comment) if $comment;`
119
- * So a declared `comment: '0'` is accepted by the API, dropped on the way to `user.cfg`, and read
120
- * back as absent. Left alone, `matches` would be false on every plan and every deploy would
121
- * rewrite the same value into the same hole. Normalising BOTH the comparison and the form is how
122
- * `values.ts` already treats `''` as the absence of a boolean rather than as `false`.
123
- * ⚠️ MEASURED IN THE SOURCE ON node-b, NOT ON THE WIRE — writing `'0'` to the cluster to watch it
124
- * vanish would have been a write, and this agent had read access only.
125
- * ⚠️ `pool.ts` HAS THE SAME LATENT HOLE — `pool:$pool:$comment:…` is written by that same
126
- * truthiness test and is not guarded. Not fixed from here; flagged so it is fixed on purpose.
127
- *
128
- * ⚠️ EVERY OTHER COMMENT ROUND-TRIPS EXACTLY, colons and newlines included: `write_user_config`
129
- * runs it through `encode_text` and the parser through `decode_text`, so the field separator in
130
- * `user.cfg` cannot be smuggled in.
131
+ * ★ Default `read` role — nothing widened for this family, see the header's privileges note.
132
+ * ⛔ `live.members === undefined` MEANS ABSENT, NOT AN EMPTY GROUP. `@distilled.cloud/core`'s
133
+ * REST protocol unwraps PVE's `{"data": null}` (a genuinely missing object — MEASURED in
134
+ * `protocol.ts`: "a Unit-output endpoint... `data` is null/absent") to `{}` BEFORE the
135
+ * schema-driven decode, not to a decode failure. `members` is the one REQUIRED field on this
136
+ * response, so its absence is the only signal that the group was never there — the same trap
137
+ * api-token-form.ts's `attributes` guards with its own required-field check.
131
138
  */
132
- const storedComment = (comment: string | undefined) => {
133
- const value = comment ?? '';
134
- return value === '0' ? '' : value;
135
- };
139
+ const readGroup = (props: GroupProps) =>
140
+ readOrUnreadable(runPve(props.target, 'read', false, access.getAccessGroup(props))).pipe(
141
+ Effect.map((live) =>
142
+ live === UNREADABLE
143
+ ? UNREADABLE
144
+ : live.members === undefined
145
+ ? undefined
146
+ : attributesOf(live, props),
147
+ ),
148
+ Effect.orElseSucceed(() => undefined),
149
+ );
136
150
 
137
- /**
138
- * The item read's `members` array, sorted into something two plans can agree on.
139
- *
140
- * ⚠️ `csv` IS REUSED RATHER THAN RE-IMPLEMENTED, AND ITS OWN ⚠️ IS EXACTLY THIS CASE: a list PVE
141
- * does not promise to give back in the order it was handed. Going out through the joined form
142
- * and back is the price of that reuse, and cheaper than a fourth hand-rolled sorter.
143
- * ⚠️ THE ARRAY ONLY — THE COMMA STRING IS NOT ACCEPTED HERE, ON PURPOSE. That spelling belongs to
144
- * the INDEX (`users`), which this provider never reads, so a branch for it would be a guess at a
145
- * shape that cannot arrive. `user.ts` refuses the same guess for `tokens` and for the same
146
- * reason: a wrong guess on a reported-only field surfaces in a plan as a phantom membership.
147
- * ⚠️ NO DEDUPLICATION, UNLIKE `user.ts`'s `groupSet`. These names are Perl HASH KEYS —
148
- * `[keys %{ $data->{users} }]` — so a duplicate is not representable. `user.groups` needs the
149
- * dedupe because its value also arrives from a hand-written prop; this one never does.
150
- */
151
- const memberList = (live: unknown): string[] => {
152
- const joined = csv((Array.isArray(live) ? live : []).map((member: unknown) => text(member)));
153
- return joined === '' ? [] : joined.split(',');
154
- };
151
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
152
+ const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
153
+ live === UNREADABLE ? undefined : live;
155
154
 
156
- const handlers = pveHandlers<GroupProps, GroupAttributes>({
157
- attributes: (live, props) => ({
158
- /**
159
- * ⚠️ ABSENT IS EMPTY, NOT MISSING. `read_group` sets `comment` only `if defined(…)`, so a group
160
- * without one answers `{"members":[…]}` and nothing else. THAT ONE IS READ OFF THE HANDLER'S
161
- * SOURCE, not off the wire — every group on this cluster happens to carry a comment, so the
162
- * commentless shape was not there to measure. The empty-MEMBERS shape WAS measured —
163
- * `automation` answers `{"comment":"…","members":[]}`. `''` is what an undeclared
164
- * `comment` prop normalises to, so the two sides meet either way.
165
- */
166
- comment: text(live['comment']),
167
- /** ⚠️ FROM PROPS: the item read's schema is `additionalProperties: 0` over comment/members. */
168
- groupid: props.groupid,
169
- members: memberList(live['members']),
170
- }),
171
- collection: () => 'access/groups',
172
- createForm: (props) => ({ comment: storedComment(props.comment), groupid: props.groupid }),
173
- /**
174
- * ⚠️ `members` IS NOT COMPARED, AND NOTHING ELSE IS LEFT TO COMPARE. `comment` is the only field
175
- * a PUT here accepts, so it is the only field whose drift this provider can repair — the rule
176
- * `resource.ts` states once in the ⛔ on adoption: a field deliberately out of `matches` is a
177
- * field this resource does not manage.
178
- */
179
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
180
- endpoint: { create: 'pve:POST /access/groups', update: 'pve:PUT /access/groups/{groupid}' },
181
- matches: (attributes, props) => attributes.comment === storedComment(props.comment),
182
- path: (props) => `access/groups/${props.groupid}`,
183
- /**
184
- * ⚠️ ALWAYS SENT, EVEN EMPTY — that is how a comment is CLEARED. `update_group` assigns
185
- * `if defined($param->{comment})`, so an omitted field leaves the old text in place and a
186
- * declaration that dropped its comment would report an update the update could not settle. The
187
- * empty string then falls out of `user.cfg` on the truthiness test above and reads back as
188
- * `''`, which is what the props side normalises to. The loop closes.
189
- */
190
- updateForm: (props) => ({ comment: storedComment(props.comment) }),
191
- });
192
-
193
- /**
194
- * ⛔ THE EMPTY `list` IS INHERITED FROM `pveHandlers` AND IT MATTERS HERE. `GET /access/groups`
195
- * answers with every group on the cluster — on this one that is `admins`, which grants
196
- * `Administrator` on `/` to every human who logs in. Handing it to Alchemy would invite adoption,
197
- * and therefore one day a delete that takes the whole admin group's access with it. Adoption
198
- * stays an explicit act, here as everywhere else in this package.
199
- */
200
155
  export const ProxmoxGroupProvider = () =>
201
- Provider.effect(ProxmoxGroup, Effect.succeed(ProxmoxGroup.Provider.of(handlers)));
156
+ Provider.effect(
157
+ ProxmoxGroup,
158
+ Effect.succeed(
159
+ ProxmoxGroup.Provider.of({
160
+ /**
161
+ * ⛔ THE EMPTY `list` MATTERS HERE. `GET /access/groups` answers every group on the
162
+ * cluster, `admins` (grants `Administrator` on `/`) included. Adoption stays explicit.
163
+ */
164
+ list: () => Effect.succeed([]),
165
+ read: Effect.fn(function* ({ olds }) {
166
+ return dropUnreadable(yield* readGroup(olds));
167
+ }),
168
+ diff: Effect.fn(function* ({ news, output }) {
169
+ if (!isResolved(news)) return undefined;
170
+ yield* guardWrite(GROUP_CREATE, createForm(news), output === undefined);
171
+ yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
172
+ if (output === undefined) return undefined;
173
+ const live = yield* readGroup(news);
174
+ // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
175
+ // `update` on a group that was plainly there — see unreadable-read.ts.
176
+ if (live === UNREADABLE) {
177
+ yield* unreadableWarning('Proxmox.Group', news.groupid);
178
+ return { action: 'noop' } as const;
179
+ }
180
+ if (live === undefined) {
181
+ // Drift: state says this group exists, the cluster disagrees. reconcile recreates it.
182
+ yield* guardWrite(GROUP_CREATE, createForm(news), true);
183
+ return { action: 'update' } as const;
184
+ }
185
+ return matches(live, news)
186
+ ? ({ action: 'noop' } as const)
187
+ : ({ action: 'update' } as const);
188
+ }),
189
+ reconcile: Effect.fn(function* ({ news }) {
190
+ // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
191
+ // write below (same lease-cache key), so `UNREADABLE` here is a narrow race, not the
192
+ // routine case `diff` handles. ⛔ UNLIKE acl.ts's PUT-only bind, a group's create is
193
+ // NOT idempotent the same way: if this narrow race DOES land as `undefined` while the
194
+ // group is genuinely already there, PVE answers "group already exists" rather than
195
+ // silently converging — a loud failure, not a silent duplicate.
196
+ const before = dropUnreadable(yield* readGroup(news));
197
+ yield* guardWrite(GROUP_CREATE, createForm(news), before === undefined);
198
+ yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
199
+ if (before === undefined) {
200
+ yield* runPve(
201
+ news.target,
202
+ 'provision',
203
+ true,
204
+ access.createAccessGroup(createForm(news)),
205
+ );
206
+ } else if (!matches(before, news)) {
207
+ yield* runPve(news.target, 'provision', true, access.putAccessGroup(updateForm(news)));
208
+ }
209
+ const after = dropUnreadable(yield* readGroup(news));
210
+ if (after === undefined) {
211
+ return yield* Effect.die(
212
+ new Error(
213
+ `access/groups/${news.groupid}: the write returned no error but the group is ` +
214
+ 'still absent. PVE wraps every answer in {"data":...} and can report success ' +
215
+ 'on a call that did nothing -- read back rather than trusting the status code.',
216
+ ),
217
+ );
218
+ }
219
+ return after;
220
+ }),
221
+ delete: Effect.fn(function* ({ olds }) {
222
+ yield* runPve(
223
+ olds.target,
224
+ 'provision',
225
+ true,
226
+ access.deleteAccessGroup({ groupid: olds.groupid }),
227
+ );
228
+ }),
229
+ }),
230
+ ),
231
+ );
@@ -47,7 +47,8 @@ import * as Cache from 'effect/Cache';
47
47
  import * as Duration from 'effect/Duration';
48
48
  import * as Effect from 'effect/Effect';
49
49
  import * as Exit from 'effect/Exit';
50
- import { type ApiTarget, type PveCredential, type PveRole, mint } from './credentials.ts';
50
+ import { type ApiTarget, type PveCredential, type PveRole } from './credentials.ts';
51
+ import { mint } from './mint.ts';
51
52
 
52
53
  /**
53
54
  * Re-mint once a lease is inside this much of its end, in seconds.
@@ -27,7 +27,8 @@ import * as Effect from 'effect/Effect';
27
27
  import { PveError, pve, pveWith } from './client.ts';
28
28
  import { guardForm } from './constraint-guard.ts';
29
29
  import type { EndpointKey } from './constraints.ts';
30
- import { type PveTarget, mint } from './credentials.ts';
30
+ import type { PveTarget } from './credentials.ts';
31
+ import { mint } from './mint.ts';
31
32
  import { createForm } from './lxc-create-form.ts';
32
33
  import { type LxcChange, judge } from './lxc-judge.ts';
33
34
  import type { LxcAttributes, LxcProps } from './lxc-props.ts';
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<role>`, alive for five
3
+ * minutes. Split out of `credentials.ts` (2026-09-24) to keep that file's TYPES under the
4
+ * 250-line cap once `PveCredentialDenied`'s catchers needed a real header there; the reasoning
5
+ * for WHY the estate mints rather than stores a token is still credentials.ts's own header.
6
+ *
7
+ * ⛔ THE SECRET NEVER TOUCHES DISK, A LOG, OR ALCHEMY STATE — see credentials.ts's ⛔. Nothing
8
+ * here returns it to a resource; it is used to build a header and then dropped.
9
+ */
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Option from 'effect/Option';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import * as Headers from 'effect/unstable/http/Headers';
14
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
15
+ import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
16
+ import { PveCredentialDenied } from './credential-errors.ts';
17
+ import type { ApiTarget, BaoEnvironment, PveCredential, PveRole } from './credentials.ts';
18
+
19
+ /** openbao v2.6.2 api/env.go:22-34 — a present BAO_* wins, even empty; else its VAULT_* twin. */
20
+ const baoVariable = (env: BaoEnvironment, name: string) =>
21
+ env[`BAO_${name}`] ?? env[`VAULT_${name}`] ?? '';
22
+
23
+ /**
24
+ * Mint one credential for `role`: `GET /v1/<mount>/creds/<role>` on OpenBao's HTTP API.
25
+ *
26
+ * 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
27
+ * client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
28
+ * learned twice what deciding by stderr costs — every failure read as absent, and two commands
29
+ * said "absent" in different words — so this reads STATUS CODES: 2xx is a credential, and 403
30
+ * (denied), 404 (no such role or mount — a mint has no "absent"), 503 (sealed) all fail.
31
+ * ★ THE ENVIRONMENT CONTRACT IS STILL THE CLI's, READ THE CLI's WAY, which keeps the case above
32
+ * true (openbao v2.6.2 api/client.go:34-66, 370, 503-507, 761-762, 776-785): BAO_AGENT_ADDR over
33
+ * BAO_ADDR over https://127.0.0.1:8200; BAO_NAMESPACE as X-Vault-Namespace; BAO_TOKEN as
34
+ * X-Vault-Token; X-Vault-Request always. <estate>/openbao/src/bao-address.ts has the long form.
35
+ * ★ AGENT MODE IS NO BAO_TOKEN AND NO TOKEN HEADER. An Agent or Proxy listener with
36
+ * `api_proxy { use_auto_auth_token = true }` (or the older `cache { … }`) substitutes its
37
+ * auto-auth token exactly when a request carries none — command/agentproxyshared/cache/handler.go
38
+ * :29-38, command/agent.go:517-541, command/agent/config/config.go:288-292, and
39
+ * website/content/docs/agent-and-proxy/agent/apiproxy.mdx:57-62. The CLI's token-file fallback
40
+ * is deliberately not read. ⚠️ So an unset BAO_TOKEN aimed at a SERVER rather than an agent is a
41
+ * 403, which reads like a missing grant rather than a missing variable.
42
+ * ★ UNIX SOCKETS: `unix:///path` is plain HTTP to `localhost` over that socket, as the CLI does it
43
+ * (api/client.go:654-667). Bun's fetch takes a `unix` option; `FetchHttpClient.RequestInit` is
44
+ * Effect's documented way to pass fetch options, read from the calling fiber (effect
45
+ * FetchHttpClient.ts:59-72). Off Bun it REFUSES, rather than reach http://localhost:80.
46
+ */
47
+ export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = process.env) =>
48
+ Effect.gen(function* () {
49
+ const path = `${target.mount}/creds/${role}`;
50
+ // ⛔ METHOD, PATH AND OpenBao's `errors` ONLY — never a header (the token), never a success
51
+ // body (the credential is IN it, so echoing it would print the secret into a log).
52
+ const refuse = (status: number, detail: string) =>
53
+ new Error(`OpenBao GET /v1/${path} -> ${status === 0 ? 'no response' : status}: ${detail}`);
54
+ const address =
55
+ baoVariable(env, 'AGENT_ADDR') || baoVariable(env, 'ADDR') || 'https://127.0.0.1:8200';
56
+ const socket = address.startsWith('unix://') ? address.slice('unix://'.length) : undefined;
57
+ const base = socket === undefined ? address.replace(/\/+$/, '') : 'http://localhost';
58
+ const headers: Record<string, string> = { 'X-Vault-Request': 'true' };
59
+ const namespace = baoVariable(env, 'NAMESPACE');
60
+ if (namespace !== '') headers['X-Vault-Namespace'] = namespace;
61
+ const token = baoVariable(env, 'TOKEN');
62
+ if (token !== '') headers['X-Vault-Token'] = token;
63
+ if (socket !== undefined && !('Bun' in globalThis)) {
64
+ return yield* Effect.fail(refuse(0, 'a unix:// address needs Bun fetch; this is not Bun'));
65
+ }
66
+
67
+ const client = yield* HttpClient.HttpClient;
68
+ // ⛔ Effect records request headers on the client span, and its default redaction list does
69
+ // not name x-vault-token (effect Headers.ts:753-762). It is added for this call.
70
+ const redacted = yield* Headers.CurrentRedactedNames;
71
+ const defaults = Option.getOrElse(
72
+ yield* Effect.serviceOption(FetchHttpClient.RequestInit),
73
+ () => ({}),
74
+ );
75
+ const request = HttpClientRequest.get(`${base}/v1/${path}`).pipe(
76
+ HttpClientRequest.setHeaders(headers),
77
+ );
78
+ const exchange = client.execute(request).pipe(
79
+ Effect.flatMap((response) =>
80
+ Effect.map(response.text, (text) => ({ status: response.status, text })),
81
+ ),
82
+ // ⚠️ The CLI's default client timeout (api/client.go:324) — a hung agent must not hang a plan.
83
+ Effect.timeout('60 seconds'),
84
+ Effect.provideService(Headers.CurrentRedactedNames, [...redacted, 'x-vault-token']),
85
+ );
86
+ // ⚠️ `| undefined` under exactOptionalPropertyTypes: `socket` is narrowed to a string
87
+ // only on the branch below, so the object literal genuinely carries
88
+ // `unix: string | undefined` and the type must say so. The alternative — building
89
+ // the key conditionally — hides that this request is the unix-socket path.
90
+ const init: RequestInit & { unix?: string | undefined } = { ...defaults, unix: socket };
91
+ const { status, text } = yield* (
92
+ socket === undefined
93
+ ? exchange
94
+ : Effect.provideService(exchange, FetchHttpClient.RequestInit, init)
95
+ ).pipe(
96
+ // ⛔ `.message` only — the error object holds the request, and the request holds the token.
97
+ Effect.mapError((cause) => refuse(0, cause.message)),
98
+ );
99
+
100
+ let parsed:
101
+ | {
102
+ lease_duration?: unknown;
103
+ data?: { token_id?: unknown; secret?: unknown };
104
+ errors?: unknown;
105
+ }
106
+ | undefined;
107
+ try {
108
+ parsed = JSON.parse(text) as typeof parsed;
109
+ } catch {
110
+ parsed = undefined;
111
+ }
112
+ if (status < 200 || status >= 300) {
113
+ const errors = parsed?.errors;
114
+ const isOpenBaoBody = Array.isArray(errors);
115
+ const detail = isOpenBaoBody ? errors.map(String).join('; ') : 'not an OpenBao error body';
116
+ /**
117
+ * ⛔ 403 GETS THE TYPED TAG ONLY WITH A GENUINE OpenBao ERROR BODY — found on adversarial
118
+ * review 2026-09-24. Status alone is not enough: a WAF, a proxy in front of OpenBao, or an
119
+ * mTLS terminator can answer 403 without OpenBao ever seeing the request, and `parsed` is
120
+ * then `undefined`/`errors` is not an array (the `try`/`catch` above, or a body that
121
+ * parses but isn't OpenBao's shape). That is an infrastructure fault, not "this identity
122
+ * is denied this role" — tagging it `PveCredentialDenied` would fold a REAL outage into
123
+ * "unreadable, report noop" exactly as quietly as the bug this fix exists to remove. Only
124
+ * a parsed `errors` array is OpenBao's own documented error shape (mint.ts's own header),
125
+ * so it is the one signal trusted here.
126
+ */
127
+ if (status === 403 && isOpenBaoBody) {
128
+ return yield* Effect.fail(new PveCredentialDenied({ detail, mount: target.mount, role }));
129
+ }
130
+ return yield* Effect.fail(refuse(status, detail || '(no errors given)'));
131
+ }
132
+ const tokenId = parsed?.data?.token_id;
133
+ const secret = parsed?.data?.secret;
134
+ if (typeof tokenId !== 'string' || typeof secret !== 'string') {
135
+ // ⛔ REFUSE RATHER THAN RETURN A HALF CREDENTIAL, and again WITHOUT quoting the body.
136
+ return yield* Effect.fail(
137
+ new Error(
138
+ `${target.mount}/creds/${role} returned no token_id/secret. Either this approle lacks ` +
139
+ `the grant, or the mount is not ${target.mount}. Say which mount and role you needed ` +
140
+ `than falling back to a stored credential -- every stored PVE token is read-only.`,
141
+ ),
142
+ );
143
+ }
144
+ const lease = parsed?.lease_duration;
145
+ const leaseSeconds = typeof lease === 'number' ? lease : 0;
146
+ return { leaseSeconds, secret, tokenId } satisfies PveCredential;
147
+ });
148
+
149
+ /**
150
+ * The header PVE wants. `PVEAPIToken=<tokenid>=<secret>`.
151
+ *
152
+ * ⚠️ THE SEPARATOR IS `=`, NOT `:`. Proxmox rejects the colon form with a 401 that says only
153
+ * "authentication failure", which reads as a bad credential rather than as a malformed header.
154
+ */
155
+ export const authorization = (credential: PveCredential, target: ApiTarget): string =>
156
+ target.scheme === 'pbs'
157
+ ? `PBSAPIToken=${credential.tokenId}:${credential.secret}`
158
+ : `PVEAPIToken=${credential.tokenId}=${credential.secret}`;
@@ -52,7 +52,7 @@ import * as Provider from 'alchemy/Provider';
52
52
  import * as Effect from 'effect/Effect';
53
53
  import { guardNetworkApply } from './apply-endpoints.ts';
54
54
  import { pveWith } from './client.ts';
55
- import { mint } from './credentials.ts';
55
+ import { mint } from './mint.ts';
56
56
  import { awaitTask, degradedReason, pendingCount } from './network-apply-read.ts';
57
57
  import type { PveRequirements } from './resource.ts';
58
58
  import type { WithTarget } from './resource.ts';
@@ -61,6 +61,17 @@ const text = (key: string, value: string) => (value === '' ? {} : { [key]: value
61
61
  /** Every item GET the scripts and the providers make, keyed `access/…` without a leading slash. */
62
62
  export const apiView = (state: CliState): Record<string, unknown> => {
63
63
  const view: Record<string, unknown> = { 'access/acl': state.acl };
64
+ /**
65
+ * ★ THE LIST SHAPE, ADDED FOR role.ts's DISTILLED MIGRATION (2026-09-24). `Proxmox.Role` now
66
+ * reads `GET /access/roles` (role.ts's header: the item endpoint's generated schema has a
67
+ * fixed privilege enumeration this fixture must not rely on). The fake CLI's own `pvesh get
68
+ * /access/roles/{id}` calls (provision-bootstrap.ts's script) still use the ITEM entries
69
+ * below, so both stay populated from the same `state.roles`.
70
+ */
71
+ view['access/roles'] = Object.entries(state.roles).map(([roleid, privs]) => ({
72
+ privs: privs.join(','),
73
+ roleid,
74
+ }));
64
75
  for (const [id, privs] of Object.entries(state.roles)) {
65
76
  view[`access/roles/${id}`] = Object.fromEntries(privs.map((priv) => [priv, 1]));
66
77
  }
@@ -37,7 +37,12 @@ const bootstrapped = async (): Promise<CliState> => {
37
37
 
38
38
  const engineOn = (state: CliState) => {
39
39
  const live = apiView(state);
40
- const fake = fakePve((call) => (call.method === 'GET' ? live[call.path] : 'UPID:fake'));
40
+ // ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}` label
41
+ // substitution (`hf-provision@pve` -> `hf-provision%40pve`), where `apiView`'s keys stay
42
+ // literal (it also serves the raw shell-CLI fake, which never sees a URL at all).
43
+ const fake = fakePve((call) =>
44
+ call.method === 'GET' ? live[decodeURIComponent(call.path)] : 'UPID:fake',
45
+ );
41
46
  const providers = Layer.mergeAll(
42
47
  ProxmoxAclProvider(),
43
48
  ProxmoxGroupProvider(),
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `Proxmox.Role`'s wire shape: PVE's create/update forms and how a live list row becomes
3
+ * attributes. Split out of role.ts (2026-09-24, the distilled migration) to keep that file
4
+ * under the 250-line cap — the api-token.ts/api-token-form.ts seam.
5
+ */
6
+ import type * as access from '@distilled.cloud/proxmox/access';
7
+ import type { RoleAttributes, RoleProps } from './role.ts';
8
+
9
+ export const ROLE_CREATE = 'pve:POST /access/roles';
10
+ export const ROLE_UPDATE = 'pve:PUT /access/roles/{roleid}';
11
+
12
+ /**
13
+ * The only shape two privilege sets may be compared in: sorted, de-duplicated, blanks dropped.
14
+ *
15
+ * ⚠️ THE TRIM AND THE EMPTY FILTER ARE NOT DECORATION. `'A,B,'.split(',')` yields a trailing `''`,
16
+ * and a hand-written list is quite likely to carry a stray space after a comma. Either one turns
17
+ * into a phantom member that no live answer can contain, so `matches` would be false on every
18
+ * plan and the deploy would rewrite the role to exactly what it already was.
19
+ */
20
+ export const canonical = (privs: readonly string[]): string[] =>
21
+ [...new Set(privs.map((priv) => priv.trim()).filter((priv) => priv.length > 0))].sort();
22
+
23
+ export const createForm = (props: RoleProps): access.CreateAccessRoleRequest => ({
24
+ privs: canonical(props.privs).join(','),
25
+ roleid: props.roleid,
26
+ });
27
+
28
+ /**
29
+ * ⚠️ NO `append` FIELD, DELIBERATELY. Sending `append=1` would make every update additive, so a
30
+ * privilege could be granted from here but never taken away — see role.ts's own header.
31
+ */
32
+ export const updateForm = (props: RoleProps): access.PutAccessRoleRequest => ({
33
+ privs: canonical(props.privs).join(','),
34
+ roleid: props.roleid,
35
+ });
36
+
37
+ export const find = (rows: readonly access.ListAccessRolesResponseBodyItem[], roleid: string) =>
38
+ rows.find((row) => row.roleid === roleid);
39
+
40
+ export const attributesOf = (
41
+ row: access.ListAccessRolesResponseBodyItem,
42
+ props: RoleProps,
43
+ ): RoleAttributes => ({
44
+ privs: canonical(row.privs === undefined ? [] : row.privs.split(',')),
45
+ roleid: props.roleid,
46
+ });
47
+
48
+ export const matches = (attributes: RoleAttributes, props: RoleProps) =>
49
+ canonical(attributes.privs).join(',') === canonical(props.privs).join(',');
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `Proxmox.Role` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
3
+ * `@distilled.cloud/proxmox` protocol — create, drift, and delete, reading via the LIST (not the
4
+ * item) as role.ts's header explains. adopt-noop.test.ts is the cross-family "adopting a
5
+ * matching object only reads" proof; this file is Role's own write paths.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+ import { engineOver } from '../verify/fake-engine.ts';
11
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
12
+ import { ProxmoxRole, ProxmoxRoleProvider } from './role.ts';
13
+
14
+ type Row = { roleid: string; privs: string | undefined };
15
+
16
+ /** `GET /access/roles` (list) and `POST`/`PUT`/`DELETE .../roles(/{roleid})`, one flat list. */
17
+ const cluster = (rows: Row[]) =>
18
+ fakePve((call: PveCall) => {
19
+ if (call.method === 'GET' && call.path === 'access/roles') return rows;
20
+ const match = /^access\/roles(?:\/(.+))?$/.exec(call.path);
21
+ if (match === null) return undefined;
22
+ const roleid = match[1];
23
+ if (call.method === 'POST' && roleid === undefined) {
24
+ rows.push({ privs: call.form['privs'], roleid: call.form['roleid'] ?? '' });
25
+ return undefined;
26
+ }
27
+ if (call.method === 'PUT' && roleid !== undefined) {
28
+ const idx = rows.findIndex((r) => r.roleid === roleid);
29
+ if (idx !== -1) rows[idx] = { privs: call.form['privs'], roleid };
30
+ return undefined;
31
+ }
32
+ if (call.method === 'DELETE' && roleid !== undefined) {
33
+ const idx = rows.findIndex((r) => r.roleid === roleid);
34
+ if (idx !== -1) rows.splice(idx, 1);
35
+ return undefined;
36
+ }
37
+ return undefined;
38
+ });
39
+
40
+ const declare = (privs: string[]) =>
41
+ ProxmoxRole('mint', { privs, roleid: 'MintTokens', target: FAKE_TARGET });
42
+
43
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
44
+ engineOver(ProxmoxRoleProvider().pipe(Layer.provideMerge(fake.layer)));
45
+
46
+ describe('Proxmox.Role over the distilled protocol', () => {
47
+ test('creating a role that does not exist yet: one POST, privileges sorted and joined', async () => {
48
+ const rows: Row[] = [];
49
+ const fake = cluster(rows);
50
+ await withoutBao(async () => {
51
+ const engine = engineFor(fake);
52
+ expect(await engine.deploy(declare(['User.Modify', 'Sys.Audit']))).toEqual({
53
+ mint: 'create',
54
+ });
55
+ });
56
+ expect(fake.writes()).toEqual(['POST access/roles']);
57
+ expect(rows).toEqual([{ privs: 'Sys.Audit,User.Modify', roleid: 'MintTokens' }]);
58
+ });
59
+
60
+ test('a drifted privilege set is corrected with exactly one PUT, no append field', async () => {
61
+ const rows: Row[] = [{ privs: 'Sys.Audit', roleid: 'MintTokens' }];
62
+ const fake = cluster(rows);
63
+ await withoutBao(async () => {
64
+ const engine = engineFor(fake);
65
+ const declared = () => declare(['Sys.Audit', 'User.Modify']);
66
+ const report = await engine.verify(declared());
67
+ expect(report.rows[0]).toMatchObject({ diff: 'update' });
68
+ expect(await engine.deploy(declared())).toEqual({ mint: 'adopted' });
69
+ });
70
+ const put = fake.calls.find((call) => call.method === 'PUT');
71
+ expect(put?.form['privs']).toBe('Sys.Audit,User.Modify');
72
+ expect(put?.pairs.some(([name]) => name === 'append')).toBe(false);
73
+ expect(rows).toEqual([{ privs: 'Sys.Audit,User.Modify', roleid: 'MintTokens' }]);
74
+ });
75
+
76
+ // ⚠️ A role does not protect itself the way `destroy` in resource.ts describes for other
77
+ // objects (role.ts's own ⛔) — deleting one always sends the DELETE.
78
+ test('deleting a role sends the DELETE', async () => {
79
+ const rows: Row[] = [{ privs: 'Sys.Audit', roleid: 'MintTokens' }];
80
+ const fake = cluster(rows);
81
+ await withoutBao(async () => {
82
+ const engine = engineFor(fake);
83
+ await engine.deploy(declare(['Sys.Audit']));
84
+ expect(await engine.deploy(Effect.void)).toEqual({});
85
+ });
86
+ expect(fake.writes()).toEqual(['DELETE access/roles/MintTokens']);
87
+ expect(rows).toEqual([]);
88
+ });
89
+ });