@homeflare/alchemy 0.30.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 (157) hide show
  1. package/README.md +22 -0
  2. package/dist/discord/application-command.d.ts +52 -0
  3. package/dist/discord/application-command.d.ts.map +1 -0
  4. package/dist/discord/command-form.d.ts +62 -0
  5. package/dist/discord/command-form.d.ts.map +1 -0
  6. package/dist/discord/credentials.d.ts +35 -0
  7. package/dist/discord/credentials.d.ts.map +1 -0
  8. package/dist/discord/fake-discord.d.ts +44 -0
  9. package/dist/discord/fake-discord.d.ts.map +1 -0
  10. package/dist/discord/guild-application-command.d.ts +53 -0
  11. package/dist/discord/guild-application-command.d.ts.map +1 -0
  12. package/dist/discord/index.d.ts +12 -0
  13. package/dist/discord/index.d.ts.map +1 -0
  14. package/dist/discord/index.js +193 -0
  15. package/dist/discord/index.js.map +15 -0
  16. package/dist/discord/providers.d.ts +15 -0
  17. package/dist/discord/providers.d.ts.map +1 -0
  18. package/dist/discord/resource.d.ts +75 -0
  19. package/dist/discord/resource.d.ts.map +1 -0
  20. package/dist/proxmox/acl-wire.d.ts +26 -0
  21. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  22. package/dist/proxmox/acl.d.ts.map +1 -1
  23. package/dist/proxmox/api-token-form.d.ts +28 -26
  24. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  25. package/dist/proxmox/api-token.d.ts +48 -89
  26. package/dist/proxmox/api-token.d.ts.map +1 -1
  27. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  28. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  29. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  30. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  31. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  32. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  33. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  34. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  35. package/dist/proxmox/client.d.ts +2 -2
  36. package/dist/proxmox/client.d.ts.map +1 -1
  37. package/dist/proxmox/credential-errors.d.ts +27 -0
  38. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  39. package/dist/proxmox/credentials.d.ts +0 -38
  40. package/dist/proxmox/credentials.d.ts.map +1 -1
  41. package/dist/proxmox/distilled-guard.d.ts +31 -0
  42. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  43. package/dist/proxmox/distilled-pve.d.ts +11 -4
  44. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  45. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  47. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  48. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  49. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  50. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  51. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  52. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  53. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  54. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  55. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  56. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  57. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  58. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  59. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  60. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  61. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  62. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  63. package/dist/proxmox/group-wire.d.ts +29 -0
  64. package/dist/proxmox/group-wire.d.ts.map +1 -0
  65. package/dist/proxmox/group.d.ts +20 -24
  66. package/dist/proxmox/group.d.ts.map +1 -1
  67. package/dist/proxmox/index.js +725 -509
  68. package/dist/proxmox/index.js.map +40 -33
  69. package/dist/proxmox/lease-cache.d.ts +1 -1
  70. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  71. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  72. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  73. package/dist/proxmox/mint.d.ts +50 -0
  74. package/dist/proxmox/mint.d.ts.map +1 -0
  75. package/dist/proxmox/network-apply-read.d.ts +2 -2
  76. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  77. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  78. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  79. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  80. package/dist/proxmox/resource.d.ts +4 -4
  81. package/dist/proxmox/role-wire.d.ts +28 -0
  82. package/dist/proxmox/role-wire.d.ts.map +1 -0
  83. package/dist/proxmox/role.d.ts +36 -52
  84. package/dist/proxmox/role.d.ts.map +1 -1
  85. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  86. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  87. package/dist/proxmox/unreadable-read.d.ts +50 -0
  88. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  89. package/dist/proxmox/user-wire.d.ts +18 -0
  90. package/dist/proxmox/user-wire.d.ts.map +1 -0
  91. package/dist/proxmox/user.d.ts +25 -32
  92. package/dist/proxmox/user.d.ts.map +1 -1
  93. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  94. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  95. package/docs/credentials.md +9 -0
  96. package/docs/discord.md +129 -0
  97. package/docs/upstream-conformance.md +17 -1
  98. package/package.json +5 -1
  99. package/src/discord/application-command.test.ts +103 -0
  100. package/src/discord/application-command.ts +109 -0
  101. package/src/discord/command-form.test.ts +144 -0
  102. package/src/discord/command-form.ts +133 -0
  103. package/src/discord/credentials.ts +36 -0
  104. package/src/discord/fake-discord.ts +70 -0
  105. package/src/discord/guild-application-command.test.ts +215 -0
  106. package/src/discord/guild-application-command.ts +129 -0
  107. package/src/discord/index.ts +31 -0
  108. package/src/discord/providers.ts +18 -0
  109. package/src/discord/resource.ts +164 -0
  110. package/src/discord/retry.test.ts +74 -0
  111. package/src/proxmox/acl-wire.ts +83 -0
  112. package/src/proxmox/acl.ts +29 -82
  113. package/src/proxmox/adopt-noop.test.ts +18 -0
  114. package/src/proxmox/api-token-adopt.test.ts +28 -12
  115. package/src/proxmox/api-token-form.ts +50 -93
  116. package/src/proxmox/api-token.ts +149 -135
  117. package/src/proxmox/client.ts +2 -7
  118. package/src/proxmox/constraints-forms.test.ts +7 -27
  119. package/src/proxmox/credential-errors.ts +33 -0
  120. package/src/proxmox/credentials.test.ts +29 -3
  121. package/src/proxmox/credentials.ts +4 -134
  122. package/src/proxmox/distilled-guard.ts +42 -0
  123. package/src/proxmox/distilled-pve.ts +15 -4
  124. package/src/proxmox/generated/constraints/index.ts +1 -1
  125. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  126. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  127. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  128. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  129. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  130. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  131. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  132. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  133. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  134. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  135. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  136. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  137. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  138. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  139. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  140. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  141. package/src/proxmox/group-wire.ts +63 -0
  142. package/src/proxmox/group.test.ts +110 -0
  143. package/src/proxmox/group.ts +135 -105
  144. package/src/proxmox/lease-cache.ts +2 -1
  145. package/src/proxmox/lxc-lifecycle.ts +2 -1
  146. package/src/proxmox/mint.ts +158 -0
  147. package/src/proxmox/network-apply.ts +1 -1
  148. package/src/proxmox/provision-cli-fake.ts +11 -0
  149. package/src/proxmox/provision-declare.test.ts +6 -1
  150. package/src/proxmox/role-wire.ts +49 -0
  151. package/src/proxmox/role.test.ts +89 -0
  152. package/src/proxmox/role.ts +147 -106
  153. package/src/proxmox/unreadable-read.test.ts +83 -0
  154. package/src/proxmox/unreadable-read.ts +60 -0
  155. package/src/proxmox/user-wire.ts +112 -0
  156. package/src/proxmox/user.test.ts +142 -0
  157. package/src/proxmox/user.ts +132 -129
@@ -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
+ });
@@ -2,54 +2,74 @@
2
2
  * `Proxmox.Role` — a PVE role: one name, one set of privileges. The object every other resource
3
3
  * in this package stands on, because a role is what the provision credential actually holds.
4
4
  *
5
- * ★ THIS IS WHERE pool.ts's ⚠️ STOPS BEING A MEMORY. That file records, in prose, that the
6
- * provision role lacked `Pool.Allocate`, that reconcile answered "Permission check failed", and
7
- * that a human widened the role over SSH with `pveum role modify`. Prose cannot be planned
8
- * against: it goes stale the first time somebody edits the role in the UI and tells nobody.
9
- * Declared here, the same fact is a diff — the privileges the role is SUPPOSED to hold are read
10
- * off the cluster on every plan, and a hand edit shows up as `1 to update` rather than as a 403
11
- * six weeks later in the middle of something else.
12
- *
13
- * ⛔ THE INDEX AND THE ITEM DISAGREE ABOUT WHAT `privs` IS, AND MISSING THAT COSTS A DIFF THAT
14
- * NEVER CONVERGES. `GET /access/roles` (the index) reports each role's privileges as a COMMA
15
- * STRING. `GET /access/roles/{roleid}` — the path this resource reads — returns a privilege MAP
16
- * instead, `{"VM.Allocate":1,"Sys.Audit":1,…}`, and its key order is a Perl hash's order, so it
17
- * is not stable between two calls to the same endpoint. Compare the raw string, or the raw
18
- * object, or the keys in the order they arrived, and every plan reports an update forever.
19
- * `canonical` below is the whole answer: both sides become a sorted, de-duplicated set before
20
- * anything is compared, and `attributes` STORES the sorted form so Alchemy's state does not
21
- * churn either.
5
+ * ★ THIS IS WHERE pool.ts's ⚠️ STOPS BEING A MEMORY. Declared here, a hand edit shows up as
6
+ * `1 to update` rather than as a 403 six weeks later in the middle of something else.
22
7
  *
23
8
  * ⚠️ A DECLARATION REPLACES THE PRIVILEGE SET; IT DOES NOT ADD TO IT. `PUT /access/roles/{roleid}`
24
- * accepts an `append` flag and this resource deliberately never sends it — with append a role
25
- * could only ever grow, so `matches` would report an update forever whenever props were a subset
26
- * of live, which is the same perpetual diff by a different road. The consequence is the one that
27
- * bites: anything a human added by hand is REVOKED on the next deploy unless it is in `privs`.
28
- * Copy the live set out of `pveum role list` before declaring a role that already exists; do not
29
- * type it from memory.
9
+ * accepts an `append` flag and this resource deliberately never sends it (role-wire.ts) — with
10
+ * append a role could only ever grow, the same perpetual diff by a different road. The
11
+ * consequence: anything a human added by hand is REVOKED on the next deploy unless it is in
12
+ * `privs`. Copy the live set out of `pveum role list` before declaring a role that already
13
+ * exists; do not type it from memory.
30
14
  *
31
15
  * ⛔ A ROLE CAN LOCK ITS OWN PROVIDER OUT, AND NOTHING IN THIS PACKAGE CAN UNDO IT. Reconcile runs
32
16
  * as the provision credential, and that credential holds a role. Declare THAT role without the
33
17
  * privileges the provider needs — `Sys.Modify` on `/access` above all — and the write succeeds,
34
18
  * after which every later plan reads 403 and the repair has to happen out of band as `root@pam`
35
- * over SSH. It is a one-way door, which is why the privileges a reconcile needs are written
36
- * down at the bottom of this comment rather than left to be rediscovered from an error message.
19
+ * over SSH.
37
20
  *
38
21
  * ⚠️ PVE REFUSES TO EDIT ITS OWN BUILT-IN ROLES (`Administrator`, `NoAccess`, the `PVE*` set), and
39
- * the item endpoint gives no sign of which those are — only the index carries the `special`
40
- * flag. A declaration aimed at a built-in role therefore READS BACK CLEANLY and fails at write
41
- * time, which reads like a broken provider rather than like a refusal. Declare roles you own.
22
+ * the LIST read (below) DOES carry the `special` flag that marks them, unlike the item endpoint
23
+ * this file used before the distilled migration — `role-wire.ts`'s `attributesOf` does not use
24
+ * it, but a caller reading `list`'s own answer could. A declaration aimed at a built-in role
25
+ * still reads back cleanly and fails at write time. Declare roles you own.
42
26
  *
43
27
  * ★ PRIVILEGES A RECONCILE NEEDS: `Sys.Audit` on `/access` to read and diff, `Sys.Modify` on
44
- * `/access` to create, update or delete. Not `Permissions.Modify` — binding a role to a user is
45
- * `/access/acl`, a different object, and nothing here writes it. An auditor-shaped credential
46
- * can already PLAN this resource and cannot DEPLOY it, which is the honest failure: the plan is
47
- * true and the deploy says exactly which privilege is missing.
28
+ * `/access` to create, update or delete. Not `Permissions.Modify` — that is a different object.
29
+ *
30
+ * ★ READ VIA THE LIST, NOT THE ITEM — A DELIBERATE CHANGE FROM THE PRE-MIGRATION CODE, AND THE
31
+ * REASON IS THE ITEM'S OWN GENERATED SCHEMA. `access.getAccessRole`'s response
32
+ * (`GetAccessRoleResponse`) enumerates a FIXED list of ~47 known privilege field names
33
+ * (`Sys_Modify`, `VM_Allocate`, …), each its own optional property — MEASURED in
34
+ * `@distilled.cloud/proxmox`'s generated `access.ts`. A privilege this package's generator did
35
+ * not know about would silently not appear as a field at all, and this file's old `privs: []`
36
+ * reconstruction would drop it from state without a diff ever seeing the loss. The LIST read
37
+ * (`access.listAccessRoles`) answers `privs` as the SAME plain comma string PVE's item read
38
+ * also disagreed with the index about pre-migration — a shape with no fixed enumeration to fall
39
+ * behind. `find` below (role-wire.ts) does the item-read's old job client-side.
40
+ * ⚠️ REASONED, NOT MEASURED, THAT THE LIST READ NEEDS NO MORE THAN `read`'s AUDITOR SHAPE. The
41
+ * item read's own privilege check (`Sys.Audit` on `/access`) was measured; the list's was not
42
+ * separately checked, but PVE's own convention is that an index read is never MORE restrictive
43
+ * than its item. `readRole` stays at the default `read` on that basis, same as before.
44
+ *
45
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` ONTO `@distilled.cloud/proxmox`'s
46
+ * typed `access.listAccessRoles`/`createAccessRole`/`putAccessRole`/`deleteAccessRole`
47
+ * (2026-09-24, decision 43's proxmox walk-down). `distilled-pve.ts`'s `runPve` replaces
48
+ * `pve()`; the cries-wolf fix is wired the same way as acl.ts, group.ts and user.ts.
48
49
  */
49
50
  import { Resource } from 'alchemy';
51
+ import { isResolved } from 'alchemy/Diff';
50
52
  import * as Provider from 'alchemy/Provider';
53
+ import * as access from '@distilled.cloud/proxmox/access';
51
54
  import * as Effect from 'effect/Effect';
52
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
55
+ import { guardWrite } from './distilled-guard.ts';
56
+ import {
57
+ ROLE_CREATE,
58
+ ROLE_UPDATE,
59
+ attributesOf,
60
+ createForm,
61
+ find,
62
+ matches,
63
+ updateForm,
64
+ } from './role-wire.ts';
65
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
66
+ import { runPve } from './distilled-pve.ts';
67
+ import {
68
+ UNREADABLE,
69
+ type Unreadable,
70
+ readOrUnreadable,
71
+ unreadableWarning,
72
+ } from './unreadable-read.ts';
53
73
 
54
74
  export interface RoleProps extends WithTarget {
55
75
  /**
@@ -62,13 +82,11 @@ export interface RoleProps extends WithTarget {
62
82
  roleid: string;
63
83
  /**
64
84
  * The COMPLETE privilege set, e.g. `['VM.Allocate', 'VM.Audit', 'Sys.Audit']`. Order and
65
- * duplicates do not matter — see the ⛔ in the header — but completeness does: this list is the
85
+ * duplicates do not matter — see the header — but completeness does: this list is the
66
86
  * role, not an addition to it.
67
87
  *
68
88
  * ⚠️ DECLARE AT LEAST ONE. A role with no privileges grants nothing and reads back as an empty
69
- * document, which is the one shape the factory cannot tell apart from an object that is not
70
- * there — reconcile would then refuse with its "the write returned no error but the object is
71
- * still absent" message, which is true of the read and misleading about the cause.
89
+ * document, which reconcile cannot tell apart from an object that is not there.
72
90
  */
73
91
  privs: string[];
74
92
  }
@@ -89,75 +107,98 @@ export interface ProxmoxRole extends Resource<
89
107
 
90
108
  export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
91
109
 
92
- /**
93
- * The only shape two privilege sets may be compared in: sorted, de-duplicated, blanks dropped.
94
- *
95
- * ⚠️ THE TRIM AND THE EMPTY FILTER ARE NOT DECORATION. `'A,B,'.split(',')` yields a trailing `''`,
96
- * and a hand-written list is quite likely to carry a stray space after a comma. Either one turns
97
- * into a phantom member that no live answer can contain, so `matches` would be false on every
98
- * plan and the deploy would rewrite the role to exactly what it already was.
99
- */
100
- const canonical = (privs: readonly string[]): string[] =>
101
- [...new Set(privs.map((priv) => priv.trim()).filter((priv) => priv.length > 0))].sort();
102
-
103
- /**
104
- * The privileges in a live answer, from EITHER shape PVE uses for them.
105
- *
106
- * ⚠️ THE MAP BRANCH IS THE ONE THAT RUNS HERE — the item endpoint answers `{"VM.Allocate":1,…}`,
107
- * so the privilege names are the KEYS and the `1`s carry no information. The string branch
108
- * exists because the index answers `{"privs":"VM.Allocate,Sys.Audit",…}` for that same role, and
109
- * a reader that assumed the map shape for an index entry would compare the words `privs`,
110
- * `roleid` and `special` against real privileges and report drift forever.
111
- *
112
- * ⚠️ A KEY WITH NO DOT IS NOT A PRIVILEGE. Every PVE privilege is `Category.Name` — VM.Allocate,
113
- * Sys.Modify, Datastore.AllocateSpace, Pool.Audit — while the metadata PVE mixes into role
114
- * answers (`special`, marking a built-in) is a bare word. Filtering on the dot keeps a flag from
115
- * being diffed as though somebody had granted it.
116
- */
117
- const livePrivs = (live: Record<string, unknown>): string[] => {
118
- const listed = live['privs'];
119
- if (typeof listed === 'string') return canonical(listed.split(','));
120
- return canonical(Object.keys(live).filter((key) => key.includes('.')));
121
- };
110
+ /** ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement. */
111
+ const readRole = (props: RoleProps) =>
112
+ readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
113
+ Effect.map((rows) => {
114
+ if (rows === UNREADABLE) return UNREADABLE;
115
+ const row = find(rows, props.roleid);
116
+ return row === undefined ? undefined : attributesOf(row, props);
117
+ }),
118
+ Effect.orElseSucceed(() => undefined),
119
+ );
122
120
 
123
- const handlers = pveHandlers<RoleProps, RoleAttributes>({
124
- attributes: (live, props) => ({ privs: livePrivs(live), roleid: props.roleid }),
125
- collection: () => 'access/roles',
126
- /** ⚠️ PVE wants ONE comma string here, not a repeated field — `privs=A,B,C`. */
127
- createForm: (props) => ({ privs: canonical(props.privs).join(','), roleid: props.roleid }),
128
- /**
129
- * ⛔ NORMALISED ON BOTH SIDES, even though `attributes` already arrives sorted. This is a
130
- * predicate, not a fast path: the cost of trusting one side's order is not a slow plan, it is
131
- * a plan that reports an update every single time and a deploy that writes the same role back
132
- * forever. Cheap insurance against the exact trap named in the header.
133
- */
134
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
135
- endpoint: { create: 'pve:POST /access/roles', update: 'pve:PUT /access/roles/{roleid}' },
136
- matches: (attributes, props) =>
137
- canonical(attributes.privs).join(',') === canonical(props.privs).join(','),
138
- path: (props) => `access/roles/${props.roleid}`,
139
- /**
140
- * ⚠️ NO `append` FIELD, DELIBERATELY. Sending `append=1` would make every update additive, so a
141
- * privilege could be granted from here but never taken away — and a role that cannot narrow
142
- * is not a declaration. See the ⚠️ in the header for what that means for hand edits.
143
- */
144
- updateForm: (props) => ({ privs: canonical(props.privs).join(',') }),
145
- });
121
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
122
+ const dropUnreadable = (live: RoleAttributes | Unreadable | undefined) =>
123
+ live === UNREADABLE ? undefined : live;
146
124
 
147
- /**
148
- * ⛔ AN EMPTY LIST, AND NOWHERE DOES IT MATTER MORE. `GET /access/roles` returns every
149
- * role on the cluster, PVE's own built-ins included. Returning them would invite Alchemy
150
- * to adopt `Administrator` — and therefore one day to narrow or delete it. Adoption is
151
- * an explicit act, here as everywhere else in this package.
152
-
153
- *
154
- * ⛔ DO NOT CARRY THE FACTORY'S REASSURANCE OVER TO THIS ONE. `destroy` there notes that
155
- * PVE refuses to delete things still in use — a pool holding guests, a storage with
156
- * volumes — and a role is NOT protected that way. Whoever is bound to the role loses
157
- * those privileges the moment it goes, and the credential this provider runs with is
158
- * bound through exactly such a binding. Checking who holds a role before removing it is
159
- * an operator's job, and it is not done here on their behalf while they read a diff.
160
-
161
- */
162
125
  export const ProxmoxRoleProvider = () =>
163
- Provider.effect(ProxmoxRole, Effect.succeed(ProxmoxRole.Provider.of(handlers)));
126
+ Provider.effect(
127
+ ProxmoxRole,
128
+ Effect.succeed(
129
+ ProxmoxRole.Provider.of({
130
+ /**
131
+ * ⛔ AN EMPTY LIST, AND NOWHERE DOES IT MATTER MORE. `GET /access/roles` returns every
132
+ * role on the cluster, PVE's own built-ins included. Adoption is an explicit act.
133
+ */
134
+ list: () => Effect.succeed([]),
135
+ read: Effect.fn(function* ({ olds }) {
136
+ return dropUnreadable(yield* readRole(olds));
137
+ }),
138
+ diff: Effect.fn(function* ({ news, output }) {
139
+ if (!isResolved(news)) return undefined;
140
+ yield* guardWrite(ROLE_CREATE, createForm(news), output === undefined);
141
+ yield* guardWrite(ROLE_UPDATE, updateForm(news), false);
142
+ if (output === undefined) return undefined;
143
+ const live = yield* readRole(news);
144
+ // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
145
+ // `update` on a role that was plainly there — see unreadable-read.ts.
146
+ if (live === UNREADABLE) {
147
+ yield* unreadableWarning('Proxmox.Role', news.roleid);
148
+ return { action: 'noop' } as const;
149
+ }
150
+ if (live === undefined) {
151
+ yield* guardWrite(ROLE_CREATE, createForm(news), true);
152
+ return { action: 'update' } as const;
153
+ }
154
+ return matches(live, news)
155
+ ? ({ action: 'noop' } as const)
156
+ : ({ action: 'update' } as const);
157
+ }),
158
+ reconcile: Effect.fn(function* ({ news }) {
159
+ // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
160
+ // write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
161
+ // handles — and a wrongful create in that race fails loudly ("already exists")
162
+ // rather than silently duplicating, group.ts's reconcile has the same note.
163
+ const before = dropUnreadable(yield* readRole(news));
164
+ yield* guardWrite(ROLE_CREATE, createForm(news), before === undefined);
165
+ yield* guardWrite(ROLE_UPDATE, updateForm(news), false);
166
+ if (before === undefined) {
167
+ yield* runPve(
168
+ news.target,
169
+ 'provision',
170
+ true,
171
+ access.createAccessRole(createForm(news)),
172
+ );
173
+ } else if (!matches(before, news)) {
174
+ yield* runPve(news.target, 'provision', true, access.putAccessRole(updateForm(news)));
175
+ }
176
+ const after = dropUnreadable(yield* readRole(news));
177
+ if (after === undefined) {
178
+ return yield* Effect.die(
179
+ new Error(
180
+ `access/roles/${news.roleid}: the write returned no error but the role is ` +
181
+ 'still absent. PVE wraps every answer in {"data":...} and can report success ' +
182
+ 'on a call that did nothing -- read back rather than trusting the status code.',
183
+ ),
184
+ );
185
+ }
186
+ return after;
187
+ }),
188
+ /**
189
+ * ⛔ DO NOT CARRY resource.ts's DESTROY REASSURANCE OVER TO THIS ONE. PVE does not refuse
190
+ * to delete a role still in use — whoever is bound to it loses those privileges the
191
+ * moment it goes, and the credential THIS PROVIDER runs with is bound through exactly
192
+ * such a binding. Checking who holds a role before removing it is an operator's job.
193
+ */
194
+ delete: Effect.fn(function* ({ olds }) {
195
+ yield* runPve(
196
+ olds.target,
197
+ 'provision',
198
+ true,
199
+ access.deleteAccessRole({ roleid: olds.roleid }),
200
+ );
201
+ }),
202
+ }),
203
+ ),
204
+ );
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The cries-wolf fix (unreadable-read.ts), proved end to end for `Proxmox.Acl` — the reference
3
+ * migrated family — through Alchemy's own Plan and Apply, over a fake cluster whose OpenBao mint
4
+ * can be flipped to DENY the `provision` role mid-test, the way the agent lane's policy denies it
5
+ * for real (measured 2026-09-24, decision doc's "cries wolf" entry: 14 ACLs read back as absent
6
+ * and forced `update` with nothing compared).
7
+ *
8
+ * ★ ONE ENGINE, ONE TRANSPORT, A MUTABLE DENIAL FLAG — not two fakes — because the state row this
9
+ * test needs (`output !== undefined`, the exact case the bug forced `update` for) has to
10
+ * survive from the first deploy (mint allowed) into the second plan (mint denied);
11
+ * fake-engine.ts builds its in-memory state store fresh per `engineOver` call, so the same
12
+ * engine instance is reused for both, and only the fake OpenBao's answer to `/creds/provision`
13
+ * changes in between. `lease_duration: 0` on every fake mint (both branches) keeps
14
+ * lease-cache.ts cold, so the second phase genuinely re-mints and hits the denial.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import * as Layer from 'effect/Layer';
18
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
19
+ import { engineOver } from '../verify/fake-engine.ts';
20
+ import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
21
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
22
+
23
+ type Row = { path: string; type: string; ugid: string; roleid: string; propagate?: number };
24
+
25
+ /** One ACL row, plus an OpenBao mint the test can deny for `provision` mid-run. */
26
+ const clusterDenyingProvision = (rows: readonly Row[]) => {
27
+ let denyProvision = false;
28
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
29
+ const request =
30
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
31
+ const url = new URL(request.url);
32
+ if (url.pathname.includes('/creds/')) {
33
+ if (denyProvision && url.pathname.endsWith('/creds/provision')) {
34
+ return Response.json({ errors: ['permission denied'] }, { status: 403 });
35
+ }
36
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
37
+ return Response.json({ data, lease_duration: 0 });
38
+ }
39
+ const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
40
+ if (request.method === 'GET' && path === 'access/acl') return Response.json({ data: rows });
41
+ // ⛔ Any other write is a bug this test would otherwise hide: once denied, `reconcile` must
42
+ // never be reached (diff answers noop), so nothing should ever PUT here again.
43
+ return Response.json({ data: null });
44
+ };
45
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
46
+ return {
47
+ deny: () => {
48
+ denyProvision = true;
49
+ },
50
+ layer: FetchHttpClient.layer.pipe(
51
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
52
+ ),
53
+ };
54
+ };
55
+
56
+ const grant = () =>
57
+ ProxmoxAcl('auditor', {
58
+ path: '/',
59
+ roleid: 'PVEAuditor',
60
+ target: FAKE_TARGET,
61
+ type: 'user',
62
+ ugid: 'u@pve',
63
+ });
64
+
65
+ describe('the cries-wolf fix: a refused provision mint reports noop, never update', () => {
66
+ test('an already-correct grant plans noop once the credential is denied, and nothing is written', async () => {
67
+ const rows: Row[] = [
68
+ { path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
69
+ ];
70
+ const fake = clusterDenyingProvision(rows);
71
+ await withoutBao(async () => {
72
+ const engine = engineOver(ProxmoxAclProvider().pipe(Layer.provideMerge(fake.layer)));
73
+ // First deploy: mint allowed, adopts the matching grant and records a state row.
74
+ expect(await engine.deploy(grant())).toEqual({ auditor: 'adopted' });
75
+ // Now the credential's role is denied -- exactly the measured agent-lane scenario.
76
+ fake.deny();
77
+ const report = await engine.verify(grant(), { all: true });
78
+ // ⛔ THE BUG THIS PROVES FIXED: before the fix this row read 'update' with nothing compared.
79
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
80
+ expect(await engine.deploy(grant())).toEqual({ auditor: 'noop' });
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The "unreadable" outcome a refused read produces — distinct from `undefined` (genuinely
3
+ * absent) and from a real attributes object (present) — so `diff` stops treating a REFUSED read
4
+ * the same as a confirmed-gone one. Fixes the "cries wolf" bug (measured 2026-09-24): on a lane
5
+ * whose credential cannot mint `provision`, every already-correct ACL and storage row read back
6
+ * as absent, and `diff` forced `update` for every one of them without comparing a single field,
7
+ * because `.pipe(Effect.orElseSucceed(() => undefined))` folds every failure — a genuine 404, a
8
+ * network blip, AND a permission refusal — into the one "absent" value `diff` cannot tell apart.
9
+ *
10
+ * ★ WHY A SENTINEL, NOT A FAILED `diff`. Upstream's `Diff` type is exactly
11
+ * `NoopDiff | UpdateDiff | ReplaceDiff` (`packages/alchemy/src/Diff.ts@v2.0.0-beta.79`) — there
12
+ * is no fourth "unreadable" action to return. And `Plan.ts`'s resource pass runs every
13
+ * resource's `diff` under `Effect.exit`, then FAILS THE WHOLE PLAN if even one failed
14
+ * (`diffFailures.length > 0` -> `Effect.failCause`, `Plan.ts@v2.0.0-beta.79` ~lines 1711-1731).
15
+ * So making `diff` FAIL on a refused read would not report one honest row — it would abort
16
+ * `bun run plan` for every OTHER resource in the stack too, worse than today's false "update".
17
+ * `{ action: 'noop' }` plus a logged warning is the only outcome that (a) never forces a write
18
+ * on a lane that cannot prove one is needed and (b) never takes the rest of the plan down with
19
+ * it — the "noop-with-warning" reading of the fix, over the alternative of a typed plan error.
20
+ *
21
+ * ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Every other read failure (a genuine
22
+ * `Cause.TimeoutError`, `PveClusterExhausted`, a network error) keeps folding into `undefined`
23
+ * exactly as it always has — this narrows ONE typed tag out of that fold, via `catchTag`, and
24
+ * changes nothing else about it. See credential-errors.ts's own ⛔ for why 403 alone qualifies.
25
+ */
26
+ import * as Effect from 'effect/Effect';
27
+ import type { PveCredentialDenied } from './credential-errors.ts';
28
+
29
+ /** Distinct from `undefined` (absent) and from a real attributes object (present). */
30
+ export const UNREADABLE = Symbol('pve-unreadable');
31
+ export type Unreadable = typeof UNREADABLE;
32
+
33
+ /**
34
+ * Catches ONLY `PveCredentialDenied` and turns it into the sentinel, so a caller that also
35
+ * `.pipe(Effect.orElseSucceed(() => undefined))` for every OTHER failure never conflates a
36
+ * refusal with a genuine absence. Compose this INSIDE that fold, never after it.
37
+ *
38
+ * ⚠️ `E | PveCredentialDenied` IN, `E` OUT — not a generic `E` narrowed by `Exclude`. `catchTag`
39
+ * needs the tag to be STATICALLY present in the error union to typecheck at all; a bare
40
+ * unconstrained `E` gives it nothing to match against. Every caller's `runPve` already carries
41
+ * `PveCredentialDenied` explicitly (distilled-pve.ts's own ★ on why), so this signature costs
42
+ * nothing at the call site.
43
+ */
44
+ export const readOrUnreadable = <A, E, R>(
45
+ effect: Effect.Effect<A, E | PveCredentialDenied, R>,
46
+ ): Effect.Effect<A | Unreadable, E, R> =>
47
+ effect.pipe(Effect.catchTag('PveCredentialDenied', () => Effect.succeed(UNREADABLE)));
48
+
49
+ /**
50
+ * The warning `diff` logs when it reports `noop` for a row it could not actually read — visible
51
+ * in `bun run plan`'s own output (Effect's default logger), even though the Diff type has
52
+ * nowhere to carry the distinction through to the printed action. See the header for why a log
53
+ * line, not a failed diff or a fourth Diff action, is what "the plan reports" means here.
54
+ */
55
+ export const unreadableWarning = (type: string, identity: string): Effect.Effect<void> =>
56
+ Effect.logWarning(
57
+ `${type} ${identity}: the read was refused (no grant for this credential's role) -- ` +
58
+ 'reporting noop rather than forcing an update with nothing compared. Re-plan on a lane ' +
59
+ 'that can read this object before trusting this row.',
60
+ );