@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
@@ -1 +1 @@
1
- {"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,0GA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,wFACb,CAAC"}
1
+ {"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,iKA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,+IACb,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `Proxmox.Acl`'s wire shape: PVE's PUT form, identity, and how a live row becomes attributes.
3
+ * Split out of acl.ts (2026-09-24) to keep that file under the 250-line cap once the cries-wolf
4
+ * fix needed real space — the api-token.ts/api-token-form.ts seam, same reasoning: this file
5
+ * answers "what does PVE store", acl.ts answers "when has it changed" and drives the provider.
6
+ */
7
+ import type * as access from '@distilled.cloud/proxmox/access';
8
+ import { guardForm } from './constraint-guard.ts';
9
+ import type { AclAttributes, AclProps } from './acl.ts';
10
+
11
+ /** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
12
+ export const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
13
+
14
+ /** The write names the subject with a plural key that differs per kind; the read answers singular. */
15
+ const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
16
+
17
+ /** The vendor endpoint every write and every constraint check runs against. */
18
+ const ENDPOINT = 'pve:PUT /access/acl';
19
+
20
+ /** The tuple PVE keys a grant by, as PUT's fields. */
21
+ export const tuple = (props: AclProps): access.PutAccessAclRequest => ({
22
+ path: normalize(props.path),
23
+ roles: props.roleid,
24
+ [SUBJECT_FIELD[props.type]]: props.ugid,
25
+ });
26
+
27
+ /** Create and update are one call: the tuple plus the single mutable field. */
28
+ export const bind = (props: AclProps): access.PutAccessAclRequest => ({
29
+ ...tuple(props),
30
+ propagate: props.propagate === false ? '0' : '1',
31
+ });
32
+
33
+ /** `guardForm` wants a plain string-valued form; distilled's request type carries `?` instead. */
34
+ const asForm = (body: access.PutAccessAclRequest): Record<string, string> =>
35
+ Object.fromEntries(Object.entries(body).filter((e): e is [string, string] => e[1] !== undefined));
36
+
37
+ /**
38
+ * ⚠️ ALWAYS PRESENCE-CHECKED. `bind`'s output IS the create form (there is no separate,
39
+ * narrower update form for this family — see acl.ts's header), so the vendor's
40
+ * required-parameter check is never wrong to run; the two separate create/update passes the
41
+ * generic factory ran elsewhere collapse to one call here without losing coverage.
42
+ */
43
+ export const guardWrite = (props: AclProps) => guardForm(ENDPOINT, asForm(bind(props)), true);
44
+
45
+ /** What a change of this string means: not an edit, a different grant. */
46
+ export const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
47
+ [normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
48
+
49
+ const find = (rows: readonly access.ListAccessAclResponseBodyItem[], props: AclProps) =>
50
+ rows.find(
51
+ (row) =>
52
+ row.path === normalize(props.path) &&
53
+ row.type === props.type &&
54
+ row.ugid === props.ugid &&
55
+ row.roleid === props.roleid,
56
+ );
57
+
58
+ /**
59
+ * ⚠️ PVE answers `propagate` as 1/0 rather than true/false, and OMITS it when it carries the API
60
+ * default, which is ON. distilled's generated schema types it `unknown` for the same reason.
61
+ */
62
+ const propagates = (row: access.ListAccessAclResponseBodyItem) => {
63
+ const value = row.propagate;
64
+ return value === undefined || value === 1 || value === true || value === '1';
65
+ };
66
+
67
+ export const attributesOf = (
68
+ rows: readonly access.ListAccessAclResponseBodyItem[],
69
+ props: AclProps,
70
+ ): AclAttributes => {
71
+ const row = find(rows, props);
72
+ return {
73
+ bound: row !== undefined,
74
+ path: normalize(props.path),
75
+ propagate: row !== undefined && propagates(row),
76
+ roleid: props.roleid,
77
+ type: props.type,
78
+ ugid: props.ugid,
79
+ };
80
+ };
81
+
82
+ export const matches = (attributes: AclAttributes, props: AclProps) =>
83
+ attributes.bound && attributes.propagate === (props.propagate !== false);
@@ -47,9 +47,15 @@ import { isResolved } from 'alchemy/Diff';
47
47
  import * as Provider from 'alchemy/Provider';
48
48
  import * as access from '@distilled.cloud/proxmox/access';
49
49
  import * as Effect from 'effect/Effect';
50
- import { guardForm } from './constraint-guard.ts';
50
+ import { attributesOf, bind, guardWrite, identity, matches, tuple } from './acl-wire.ts';
51
51
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
52
52
  import { runPve } from './distilled-pve.ts';
53
+ import {
54
+ UNREADABLE,
55
+ type Unreadable,
56
+ readOrUnreadable,
57
+ unreadableWarning,
58
+ } from './unreadable-read.ts';
53
59
 
54
60
  /** PVE's three kinds of subject. The read answers this word; the write wants its plural. */
55
61
  export type AclSubjectType = 'user' | 'group' | 'token';
@@ -91,91 +97,23 @@ export interface ProxmoxAcl extends Resource<
91
97
 
92
98
  export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
93
99
 
94
- /** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
95
- const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
96
-
97
- /** The write names the subject with a plural key that differs per kind; the read answers singular. */
98
- const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
99
-
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 => ({
105
- path: normalize(props.path),
106
- roles: props.roleid,
107
- [SUBJECT_FIELD[props.type]]: props.ugid,
108
- });
109
-
110
- /** Create and update are one call: the tuple plus the single mutable field. */
111
- const bind = (props: AclProps): access.PutAccessAclRequest => ({
112
- ...tuple(props),
113
- propagate: props.propagate === false ? '0' : '1',
114
- });
115
-
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));
119
-
120
- /**
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.
125
- */
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
- );
140
-
141
- /**
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.
144
- */
145
- const propagates = (row: access.ListAccessAclResponseBodyItem) => {
146
- const value = row.propagate;
147
- return value === undefined || value === 1 || value === true || value === '1';
148
- };
149
-
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
100
  /**
169
101
  * ⛔ `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.
102
+ * ⛔ on filtering. `orElseSucceed` still folds every OTHER failure into `undefined`.
103
+ * ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
104
+ * "cries wolf" bug — comes back `UNREADABLE` instead of the same `undefined` a genuine
105
+ * absence produces, so `diff` below can tell them apart.
172
106
  */
173
107
  const readAttributes = (props: AclProps) =>
174
- runPve(props.target, 'provision', false, access.listAccessAcl({})).pipe(
175
- Effect.map((rows) => attributesOf(rows, props)),
108
+ readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
109
+ Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
176
110
  Effect.orElseSucceed(() => undefined),
177
111
  );
178
112
 
113
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
114
+ const dropUnreadable = (live: AclAttributes | Unreadable | undefined) =>
115
+ live === UNREADABLE ? undefined : live;
116
+
179
117
  export const ProxmoxAclProvider = () =>
180
118
  Provider.effect(
181
119
  ProxmoxAcl,
@@ -184,7 +122,7 @@ export const ProxmoxAclProvider = () =>
184
122
  /** ⛔ EMPTY LIKE EVERY OTHER PVE RESOURCE. Adopting every grant a human ever clicked is not adoption. */
185
123
  list: () => Effect.succeed([]),
186
124
  read: Effect.fn(function* ({ olds }) {
187
- return yield* readAttributes(olds);
125
+ return dropUnreadable(yield* readAttributes(olds));
188
126
  }),
189
127
  /**
190
128
  * ⛔ AN IDENTITY CHANGE IS A REPLACE. Editing `roleid` (or the subject, or the path) would
@@ -201,6 +139,12 @@ export const ProxmoxAclProvider = () =>
201
139
  yield* guardWrite(news);
202
140
  if (output === undefined) return undefined;
203
141
  const live = yield* readAttributes(news);
142
+ // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and
143
+ // force `update` on a grant that was plainly there — see unreadable-read.ts.
144
+ if (live === UNREADABLE) {
145
+ yield* unreadableWarning('Proxmox.Acl', identity(news));
146
+ return { action: 'noop' } as const;
147
+ }
204
148
  return live !== undefined && matches(live, news)
205
149
  ? ({ action: 'noop' } as const)
206
150
  : ({ action: 'update' } as const);
@@ -212,12 +156,15 @@ export const ProxmoxAclProvider = () =>
212
156
  * the gap.
213
157
  */
214
158
  reconcile: Effect.fn(function* ({ news }) {
215
- const before = yield* readAttributes(news);
159
+ // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
160
+ // write below, so `UNREADABLE` here is a near-impossible race, not the routine case
161
+ // `diff` handles — treating it as "not yet bound" costs at most one redundant PUT.
162
+ const before = dropUnreadable(yield* readAttributes(news));
216
163
  yield* guardWrite(news);
217
164
  if (before === undefined || !matches(before, news)) {
218
165
  yield* runPve(news.target, 'provision', true, access.putAccessAcl(bind(news)));
219
166
  }
220
- const after = yield* readAttributes(news);
167
+ const after = dropUnreadable(yield* readAttributes(news));
221
168
  if (after === undefined || !after.bound) {
222
169
  return yield* Effect.die(
223
170
  new Error(
@@ -16,9 +16,11 @@ import { engineOver } from '../verify/fake-engine.ts';
16
16
  import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
17
17
  import type { PbsTarget } from './credentials.ts';
18
18
  import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
19
+ import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
19
20
  import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
20
21
  import { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
21
22
  import { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
23
+ import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
22
24
 
23
25
  const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
24
26
 
@@ -54,6 +56,22 @@ const cases: readonly Case[] = [
54
56
  name: 'Proxmox.Acl (wraps the factory reconcile)',
55
57
  provider: ProxmoxAclProvider as never,
56
58
  },
59
+ {
60
+ declare: () =>
61
+ ProxmoxGroup('mint', { comment: 'fence', groupid: 'hf-mint', target: FAKE_TARGET }) as never,
62
+ live: { 'access/groups/hf-mint': { comment: 'fence', members: [] } },
63
+ name: 'Proxmox.Group (its own reconcile, since the distilled migration)',
64
+ provider: ProxmoxGroupProvider as never,
65
+ },
66
+ {
67
+ // ⚠️ KEY IS PERCENT-ENCODED — user.ts's own ⚠️: distilled's `{userid}` substitution encodes
68
+ // the `@`, and `clusterWith` below does no decoding of `call.path`.
69
+ declare: () =>
70
+ ProxmoxUser('iac', { comment: 'x', target: FAKE_TARGET, userid: 'iac@pve' }) as never,
71
+ live: { 'access/users/iac%40pve': { comment: 'x', enable: 1, expire: 0 } },
72
+ name: 'Proxmox.User (its own reconcile, since the distilled migration)',
73
+ provider: ProxmoxUserProvider as never,
74
+ },
57
75
  {
58
76
  declare: () =>
59
77
  PbsDatastore('store1', {
@@ -17,10 +17,6 @@ import { engineOver } from '../verify/fake-engine.ts';
17
17
  import { type ApiTokenProps, ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
18
18
  import { shape } from './api-token-form.ts';
19
19
  import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
20
- import type {
21
- AccessUsersUseridTokenTokenidPostParams,
22
- AccessUsersUseridTokenTokenidPutParams,
23
- } from './generated/pve.ts';
24
20
 
25
21
  type Live = { comment?: string; expire: number | string; privsep: number };
26
22
 
@@ -32,12 +28,19 @@ const tokens = new Map<string, Live>([
32
28
  ['access/users/svc@pve/token/lease2', { comment: 'lease', expire: '1789327175', privsep: 0 }],
33
29
  ]);
34
30
 
31
+ /**
32
+ * ⚠️ `decodeURIComponent` — user.ts's own ⚠️, and it applies here too: distilled's `{userid}`/
33
+ * `{tokenid}` label substitution percent-encodes `iac@pve` to `iac%40pve`, where `client.ts`'s
34
+ * plain string concatenation never did. `tokens`'s keys stay literal (`@`); only the lookup
35
+ * decodes, so this map does not have to be rewritten for a wire-level detail.
36
+ */
35
37
  const cluster = () =>
36
38
  fakePve((call: PveCall) => {
37
- if (call.method === 'GET') return tokens.get(call.path);
39
+ const key = decodeURIComponent(call.path);
40
+ if (call.method === 'GET') return tokens.get(key);
38
41
  if (call.method === 'PUT') {
39
- const live = tokens.get(call.path);
40
- if (live !== undefined) tokens.set(call.path, { ...live, ...call.form });
42
+ const live = tokens.get(key);
43
+ if (live !== undefined) tokens.set(key, { ...live, ...call.form });
41
44
  return call.form;
42
45
  }
43
46
  return undefined;
@@ -125,14 +128,27 @@ describe('adopting a metadata-only ApiToken declaration', () => {
125
128
  expect(await engine.deploy(flipped())).toEqual({ 'iac-pve-apply': 'adopted' });
126
129
  });
127
130
  const put = fake.calls.find((call) => call.method === 'PUT');
128
- expect(fake.writes()).toEqual(['PUT access/users/iac@pve/token/apply']);
131
+ // ⚠️ PERCENT-ENCODED — see the ⚠️ on `cluster` above.
132
+ expect(fake.writes()).toEqual(['PUT access/users/iac%40pve/token/apply']);
133
+ // ⛔ `userid`/`tokenid` DO NOT LEAK INTO THE WIRE FORM, even though `shape()` (below) must
134
+ // include them for distilled's `putAccessUserToken` call — they are `T.Label()` fields,
135
+ // pulled into the URL by the protocol layer and never sent in the body. MEASURED here.
129
136
  expect(put?.pairs.map(([name]) => name)).toEqual(['comment', 'expire', 'privsep']);
130
137
  });
131
138
 
132
- test('the form keys typecheck against both generated PVE params, with no cast', () => {
139
+ // ★ THE OLD PROOF ("typechecks against the generated PVE params, no cast") is now `shape()`'s
140
+ // own return type (`access.PutAccessUserTokenRequest`), checked at compile time by every
141
+ // caller. This pins the one thing that is not: `userid`/`tokenid` ARE in the object `shape`
142
+ // builds (distilled needs them to fill the URL), even though the wire test above proves
143
+ // they never reach the actual PUT body.
144
+ test('shape() carries userid/tokenid for distilled, alongside the three mutable fields', () => {
133
145
  const props = propsFor('iac@pve', 'apply', { comment: 'automation' });
134
- const post: AccessUsersUseridTokenTokenidPostParams = shape(props);
135
- const put: AccessUsersUseridTokenTokenidPutParams = shape(props);
136
- expect(post).toEqual(put);
146
+ expect(shape(props)).toEqual({
147
+ comment: 'automation',
148
+ expire: '0',
149
+ privsep: '0',
150
+ tokenid: 'apply',
151
+ userid: 'iac@pve',
152
+ });
137
153
  });
138
154
  });
@@ -15,14 +15,17 @@ import type { ApiTokenAttributes, ApiTokenProps } from './api-token.ts';
15
15
  * `update_token_info`, read over SSH on node-b on 2026-09-13. The two disagree in exactly the place
16
16
  * that costs a forever-diff: the schema gives `expire` the default "same as user", and the code
17
17
  * never implements it. api-token.ts records what that means.
18
+ *
19
+ * ★ MIGRATED OFF THE GENERATED `generated/pve.ts` PARAM TYPES ONTO `@distilled.cloud/proxmox`'s
20
+ * typed `access.PutAccessUserTokenRequest`/`access.GetAccessUserTokenResponse` (2026-09-24) —
21
+ * the same three fields, still form-urlencoded strings on the wire; distilled's generator
22
+ * reads the identical vendor schema `generated/pve.ts` came from.
18
23
  */
19
- import type {
20
- AccessUsersUseridTokenTokenidPostParams,
21
- AccessUsersUseridTokenTokenidPutParams,
22
- } from './generated/pve.ts';
23
- import type { PveSpec } from './resource.ts';
24
+ import type * as access from '@distilled.cloud/proxmox/access';
24
25
  import { bool, int, text } from './values.ts';
25
26
 
27
+ export const API_TOKEN_UPDATE = 'pve:PUT /access/users/{userid}/token/{tokenid}';
28
+
26
29
  /**
27
30
  * `<userid>!<tokenid>` — the name the rest of PVE calls this object by.
28
31
  *
@@ -38,12 +41,8 @@ import { bool, int, text } from './values.ts';
38
41
  export const fullTokenid = (props: ApiTokenProps) => `${props.userid}!${props.tokenid}`;
39
42
 
40
43
  /**
41
- * Everything a token has, in the form PVE wants. Create and update send exactly this.
42
- *
43
- * ⛔ `userid` AND `tokenid` ARE NOT IN IT, and that is not an omission. Both are path segments of
44
- * `access/users/{userid}/token/{tokenid}` — which is the path the POST goes to as well as the
45
- * PUT — so a second copy in the body can only ever disagree with the path it was sent to.
46
- * metric-server.ts omits its `id` for the same reason and says so there.
44
+ * Everything a token has, in the form PVE wants. Update sends exactly this (this family never
45
+ * creates — see api-token.ts's header).
47
46
  *
48
47
  * ⚠️ EVERY FIELD IS SENT ON EVERY WRITE, THE EMPTY COMMENT INCLUDED, BECAUSE A PUT MERGES.
49
48
  * MEASURED in `update_token_info`: `$token->{comment} = $param->{comment} if defined(...)`
@@ -54,91 +53,49 @@ export const fullTokenid = (props: ApiTokenProps) => `${props.userid}!${props.to
54
53
  * literally `my $deletable = { comment => 1 };`, and anything else answers
55
54
  * "unknown option '<k>'" — so `expire` and `privsep` have no clear path at all, which is the
56
55
  * other half of why both are required props rather than optional ones.
57
- *
58
- * ⚠️ `flag()` FROM values.ts IS DELIBERATELY NOT USED FOR `privsep`. Its entire job is turning an
59
- * UNDECLARED boolean into `undefined` so the field is omitted from the form; `privsep` is a
60
- * required prop precisely so that it is never undeclared, so there is nothing for it to do and
61
- * its `string | undefined` return would have to be asserted away to fit `Record<string,string>`.
62
- * An assertion here would be a claim about the prop that the type already makes properly.
63
- *
64
- * ★ TYPED AGAINST THE GENERATED PUT AND POST PARAMS, NOT `Record<string, string>`, since
65
- * 2026-09-23 — the `notification-matcher-form.ts` pattern: `Pick` rather than the whole type,
66
- * because neither `AccessUsersUseridTokenTokenidPutParams` nor …`PostParams` (generated/pve.ts)
67
- * carries `userid`/`tokenid` (they are path segments — see the ⛔ above) and Put alone also
68
- * carries `delete`/`regenerate`, which this family never sends. A schema drift on any of the
69
- * three fields this function DOES send fails `tsc` here rather than surfacing as a 400 on a live
70
- * cluster; api-token.test.ts pins the assignment both ways (Put and Post) without a cast.
71
- * `expire` is written as a template literal, not `String(...)`, because the generated type is
72
- * `` `${number}` ``, not `string` — `String(n)` widens to plain `string` and would not typecheck.
73
56
  */
74
- export const shape = (
75
- props: ApiTokenProps,
76
- ): Pick<AccessUsersUseridTokenTokenidPutParams, 'comment' | 'expire' | 'privsep'> &
77
- Pick<AccessUsersUseridTokenTokenidPostParams, 'comment' | 'expire' | 'privsep'> => ({
57
+ export const shape = (props: ApiTokenProps): access.PutAccessUserTokenRequest => ({
78
58
  comment: props.comment ?? '',
79
- expire: `${props.expire}`,
59
+ expire: String(props.expire),
80
60
  privsep: props.privsep ? '1' : '0',
61
+ tokenid: props.tokenid,
62
+ userid: props.userid,
81
63
  });
82
64
 
83
- export const apiTokenSpec: PveSpec<ApiTokenProps, ApiTokenAttributes> = {
84
- // ⛔ THE ITEM READ NEEDS `User.Modify`, WHICH THE AUDITOR-SHAPED READ LEASE DOES NOT HAVE. The
85
- // measurement and the consequence are in the header's last ⚠️; the mechanism — a silent
86
- // "absent" rather than a 403 — is the ⛔ on `readRole` in resource.ts.
87
- readRole: 'provision',
88
- /**
89
- * ★ NOTHING PVE REPORTS HERE IS UNWRITABLE, WHICH IS UNUSUAL IN THIS PACKAGE AND IS WHY
90
- * `matches` can compare everything it reads. The GET returns exactly `comment`, `expire` and
91
- * `privsep`; PUT accepts exactly those three. There is no autoscaled field, no server-assigned
92
- * id, no set whose order PVE reshuffles — the three killers the neighbouring files carry
93
- * ⚠️s about do not arise. The two identity fields and `fullTokenid` come from props and are
94
- * true by construction, so comparing them would be theatre.
95
- * ⚠️ `undefined` WHEN NEITHER FLAG IS THERE, BECAUSE BOTH ARE ALWAYS THERE. `user.cfg`
96
- * materialises `expire` and `privsep` for every token it stores, so an answer carrying neither
97
- * is not a token — and without this guard `bool`/`int` would invent one out of their fallbacks
98
- * and the read-back guard in `reconcile` could never fire.
99
- */
100
- attributes: (live, props) => {
101
- const expire = live['expire'];
102
- const privsep = live['privsep'];
103
- if (expire === undefined && privsep === undefined) return undefined;
104
- return {
105
- comment: text(live['comment']),
106
- expire: int(expire, 0),
107
- fullTokenid: fullTokenid(props),
108
- /** ⚠️ FALLBACK `true`, MATCHING PVE'S API DEFAULT — unreachable given the guard above, but
109
- * wrong in the safe direction if a future release stops emitting the field. */
110
- privsep: bool(privsep, true),
111
- tokenid: props.tokenid,
112
- userid: props.userid,
113
- };
114
- },
115
- /**
116
- * ⛔ THE SAME STRING AS `path`, AND NOT A TYPO. A token is POSTed to its OWN url, not to the
117
- * collection: MEASURED, `/access/users/{userid}/token` carries a GET and nothing else, while
118
- * POST lives on `{tokenid}`. metric-server.ts has the identical shape for the identical
119
- * reason and records the experiment there.
120
- */
121
- collection: (props) => `access/users/${props.userid}/token/${props.tokenid}`,
122
- createForm: shape,
123
- /** The vendor rules both forms are checked against at plan time — resource-spec.ts. */
124
- endpoint: {
125
- create: 'pve:POST /access/users/{userid}/token/{tokenid}',
126
- update: 'pve:PUT /access/users/{userid}/token/{tokenid}',
127
- },
128
- /**
129
- * ⚠️ EXACTLY THE THREE FIELDS A PUT CAN PUT BACK, WHICH IS ALSO EXACTLY WHAT THE GET REPORTS.
130
- * MEASURED, by replaying these two functions over the live GET bodies of all four token
131
- * shapes this cluster has: `metrics@pve!exporter` (no comment), `iac@pve!apply` and
132
- * `app@pve!app` (commented), and a live OpenBao lease under `hf-read@pve` (a real
133
- * non-zero `expire`, offered as both `1789327175` and `"1789327175"` since `int` must not care
134
- * which). All four answer noop; flipping `privsep` answers update; an empty body answers
135
- * absent. ⚠️ THAT IS THE COMPARISON, NOT THE ENGINE — no `alchemy plan` was run against this
136
- * family, and nothing here was written to the cluster.
137
- */
138
- matches: (attributes, props) =>
139
- attributes.comment === (props.comment ?? '') &&
140
- attributes.expire === props.expire &&
141
- attributes.privsep === props.privsep,
142
- path: (props) => `access/users/${props.userid}/token/${props.tokenid}`,
143
- updateForm: shape,
65
+ /**
66
+ * ★ NOTHING PVE REPORTS HERE IS UNWRITABLE, WHICH IS UNUSUAL IN THIS PACKAGE. The GET returns
67
+ * exactly `comment`, `expire` and `privsep`; PUT accepts exactly those three.
68
+ * ⛔ `undefined` WHEN NEITHER FLAG IS THERE, BECAUSE BOTH ARE ALWAYS THERE FOR A REAL TOKEN.
69
+ * `user.cfg` materialises `expire` and `privsep` for every token it stores, so an answer
70
+ * carrying neither is not a token — the same "distilled unwraps a missing object's `{"data":
71
+ * null}` to `{}`" trap group.ts's header measures, checked here the way this guard already was
72
+ * before the distilled migration (api-token.ts's ⛔ on the create refusal cites the same
73
+ * MEASURED schema). Without this guard `bool`/`int` would invent one out of their fallbacks and
74
+ * the read-back guard in `reconcile` could never fire.
75
+ */
76
+ export const attributesOf = (
77
+ live: access.GetAccessUserTokenResponse,
78
+ props: ApiTokenProps,
79
+ ): ApiTokenAttributes | undefined => {
80
+ if (live.expire === undefined && live.privsep === undefined) return undefined;
81
+ return {
82
+ comment: text(live.comment),
83
+ expire: int(live.expire, 0),
84
+ fullTokenid: fullTokenid(props),
85
+ /** ⚠️ FALLBACK `true`, MATCHING PVE'S API DEFAULT — unreachable given the guard above, but
86
+ * wrong in the safe direction if a future release stops emitting the field. */
87
+ privsep: bool(live.privsep, true),
88
+ tokenid: props.tokenid,
89
+ userid: props.userid,
90
+ };
144
91
  };
92
+
93
+ /**
94
+ * ⚠️ EXACTLY THE THREE FIELDS A PUT CAN PUT BACK, WHICH IS ALSO EXACTLY WHAT THE GET REPORTS.
95
+ * MEASURED (pre-migration, replaying against four live token shapes — see the old PR): all
96
+ * answer noop; flipping `privsep` answers update; an empty body answers absent.
97
+ */
98
+ export const matches = (attributes: ApiTokenAttributes, props: ApiTokenProps) =>
99
+ attributes.comment === (props.comment ?? '') &&
100
+ attributes.expire === props.expire &&
101
+ attributes.privsep === props.privsep;