@homeflare/alchemy 0.31.0 → 0.31.2

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 (133) hide show
  1. package/README.md +5 -4
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/proxmox/acl-wire.d.ts +26 -0
  5. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  6. package/dist/proxmox/acl.d.ts.map +1 -1
  7. package/dist/proxmox/api-token-form.d.ts +28 -26
  8. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  9. package/dist/proxmox/api-token.d.ts +48 -89
  10. package/dist/proxmox/api-token.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  12. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  13. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  14. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  15. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  16. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  17. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  18. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  19. package/dist/proxmox/client.d.ts +2 -2
  20. package/dist/proxmox/client.d.ts.map +1 -1
  21. package/dist/proxmox/credential-errors.d.ts +27 -0
  22. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  23. package/dist/proxmox/credentials.d.ts +0 -38
  24. package/dist/proxmox/credentials.d.ts.map +1 -1
  25. package/dist/proxmox/distilled-guard.d.ts +31 -0
  26. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  27. package/dist/proxmox/distilled-pve.d.ts +11 -4
  28. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  29. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  44. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  45. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  47. package/dist/proxmox/group-wire.d.ts +68 -0
  48. package/dist/proxmox/group-wire.d.ts.map +1 -0
  49. package/dist/proxmox/group.d.ts +20 -24
  50. package/dist/proxmox/group.d.ts.map +1 -1
  51. package/dist/proxmox/index.js +730 -511
  52. package/dist/proxmox/index.js.map +40 -33
  53. package/dist/proxmox/lease-cache.d.ts +1 -1
  54. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  55. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  56. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  57. package/dist/proxmox/mint.d.ts +50 -0
  58. package/dist/proxmox/mint.d.ts.map +1 -0
  59. package/dist/proxmox/network-apply-read.d.ts +2 -2
  60. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  61. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  62. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  63. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  64. package/dist/proxmox/resource.d.ts +4 -4
  65. package/dist/proxmox/role-wire.d.ts +28 -0
  66. package/dist/proxmox/role-wire.d.ts.map +1 -0
  67. package/dist/proxmox/role.d.ts +36 -52
  68. package/dist/proxmox/role.d.ts.map +1 -1
  69. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  70. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  71. package/dist/proxmox/unreadable-read.d.ts +50 -0
  72. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  73. package/dist/proxmox/user-wire.d.ts +47 -0
  74. package/dist/proxmox/user-wire.d.ts.map +1 -0
  75. package/dist/proxmox/user.d.ts +25 -32
  76. package/dist/proxmox/user.d.ts.map +1 -1
  77. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  78. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  79. package/dist/verify/fake-engine.d.ts +14 -0
  80. package/dist/verify/fake-engine.d.ts.map +1 -1
  81. package/docs/grafana.md +47 -17
  82. package/docs/upstream-conformance.md +14 -4
  83. package/package.json +2 -2
  84. package/src/grafana/index.ts +6 -5
  85. package/src/proxmox/acl-wire.ts +83 -0
  86. package/src/proxmox/acl.ts +36 -83
  87. package/src/proxmox/adopt-noop.test.ts +18 -0
  88. package/src/proxmox/api-token-adopt.test.ts +28 -12
  89. package/src/proxmox/api-token-form.ts +50 -93
  90. package/src/proxmox/api-token.ts +155 -133
  91. package/src/proxmox/client.ts +2 -7
  92. package/src/proxmox/constraints-forms.test.ts +7 -27
  93. package/src/proxmox/credential-errors.ts +33 -0
  94. package/src/proxmox/credentials.test.ts +29 -3
  95. package/src/proxmox/credentials.ts +4 -134
  96. package/src/proxmox/distilled-guard.ts +42 -0
  97. package/src/proxmox/distilled-pve.ts +15 -4
  98. package/src/proxmox/generated/constraints/index.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  100. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/group-wire.ts +117 -0
  116. package/src/proxmox/group.test.ts +110 -0
  117. package/src/proxmox/group.ts +127 -109
  118. package/src/proxmox/lease-cache.ts +2 -1
  119. package/src/proxmox/lxc-lifecycle.ts +2 -1
  120. package/src/proxmox/mint.ts +158 -0
  121. package/src/proxmox/network-apply.ts +1 -1
  122. package/src/proxmox/provision-cli-fake.ts +11 -0
  123. package/src/proxmox/provision-declare.test.ts +6 -1
  124. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  125. package/src/proxmox/role-wire.ts +49 -0
  126. package/src/proxmox/role.test.ts +89 -0
  127. package/src/proxmox/role.ts +152 -104
  128. package/src/proxmox/unreadable-read.test.ts +83 -0
  129. package/src/proxmox/unreadable-read.ts +60 -0
  130. package/src/proxmox/user-wire.ts +148 -0
  131. package/src/proxmox/user.test.ts +142 -0
  132. package/src/proxmox/user.ts +136 -131
  133. package/src/verify/fake-engine.ts +23 -0
@@ -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
+ );
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `Proxmox.User`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
3
+ * and the read itself. Split out of user.ts (2026-09-24, the distilled migration) to keep that
4
+ * file under the 250-line cap — the api-token.ts/api-token-form.ts seam.
5
+ */
6
+ import * as access from '@distilled.cloud/proxmox/access';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
9
+ import type { UserAttributes, UserProps } from './user.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
11
+ import { bool, text } from './values.ts';
12
+
13
+ export const USER_CREATE = 'pve:POST /access/users';
14
+ export const USER_UPDATE = 'pve:PUT /access/users/{userid}';
15
+
16
+ /**
17
+ * ⚠️ MORE FORGIVING THAN `lxc.ts`'s `num`, ON PURPOSE, AND DELIBERATELY NOT `values.ts`'s
18
+ * `int` (a different function of the same name, `Number.parseInt` on a STRING with a
19
+ * caller-given fallback). PVE's JSON is generated from a Perl schema and an integer field can
20
+ * arrive as `0` or `"0"` depending on the release and the endpoint; `Number(value ?? 0)`
21
+ * accepts either without a strict `typeof === 'number'` that would silently fall back for the
22
+ * string form and report an update `matches` can never settle.
23
+ */
24
+ const int = (value: unknown) => {
25
+ const parsed = typeof value === 'number' ? value : Number(value ?? 0);
26
+ return Number.isFinite(parsed) ? parsed : 0;
27
+ };
28
+
29
+ /**
30
+ * An array (what the read returns), a comma string (what older releases and the write side use), or
31
+ * nothing — in; a sorted, deduplicated list out. Both sides of `matches` go through this, which is
32
+ * the only reason the diff ever settles.
33
+ */
34
+ const groupSet = (value: unknown): string[] => {
35
+ const raw: unknown[] = Array.isArray(value)
36
+ ? value
37
+ : typeof value === 'string'
38
+ ? value.split(',')
39
+ : [];
40
+ const names = raw.map((group) => text(group).trim()).filter((group) => group !== '');
41
+ return [...new Set(names)].sort();
42
+ };
43
+
44
+ /**
45
+ * ⚠️ THE SHAPE DEPENDS ON WHICH ENDPOINT YOU ASKED. Reading ONE user gives an object keyed by
46
+ * token name; distilled types it `tokens?: unknown` for the same reason (the item and index
47
+ * responses disagree, and this provider only ever reads the item). Anything but that shape is
48
+ * reported as "no tokens" rather than guessed at.
49
+ */
50
+ const tokenNames = (value: unknown): string[] =>
51
+ typeof value === 'object' && value !== null && !Array.isArray(value)
52
+ ? Object.keys(value).sort()
53
+ : [];
54
+
55
+ /** Everything mutable, in the form PVE wants. Create adds `userid`; update sends exactly this. */
56
+ const shape = (props: UserProps) => ({
57
+ comment: props.comment ?? '',
58
+ email: props.email ?? '',
59
+ enable: props.enable === false ? '0' : '1',
60
+ expire: String(props.expire ?? 0),
61
+ firstname: props.firstname ?? '',
62
+ /**
63
+ * ⚠️ ALWAYS SENT, EVEN EMPTY. Omitting `groups` on a PUT leaves the existing memberships in
64
+ * place, so a group removed from the declaration would never actually be removed. Same for
65
+ * `email` and `comment`: the empty string is how PVE is told to clear a field.
66
+ * ⚠️ AND `append` IS DELIBERATELY NOT SET. Its default (0) REPLACES the list; setting it to 1
67
+ * would turn every reconcile into an add-only merge, which is the same non-settling diff.
68
+ */
69
+ groups: groupSet(props.groups).join(','),
70
+ lastname: props.lastname ?? '',
71
+ });
72
+
73
+ export const createForm = (props: UserProps): access.CreateAccessUserRequest => ({
74
+ ...shape(props),
75
+ userid: props.userid,
76
+ });
77
+
78
+ export const updateForm = (props: UserProps): access.PutAccessUserRequest => ({
79
+ ...shape(props),
80
+ userid: props.userid,
81
+ });
82
+
83
+ const attributesOf = (live: access.GetAccessUserResponse, props: UserProps): UserAttributes => ({
84
+ comment: text(live.comment),
85
+ email: text(live.email),
86
+ /**
87
+ * ⚠️ ABSENT MEANS ENABLED. PVE's schema defaults `enable` to 1 and does not always write the
88
+ * key back for an enabled account. Reading absence as `false` would report an update on
89
+ * every plan and then DISABLE the account on the deploy that "fixed" it.
90
+ */
91
+ enable: bool(live.enable, true),
92
+ expire: int(live.expire),
93
+ firstname: text(live.firstname),
94
+ groups: groupSet(live.groups),
95
+ lastname: text(live.lastname),
96
+ tokens: tokenNames(live.tokens),
97
+ /** ⚠️ FROM PROPS: `GET /access/users/{userid}` does not echo the userid back in its data. */
98
+ userid: props.userid,
99
+ });
100
+
101
+ /**
102
+ * ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
103
+ * what a PUT can put back; a field PVE reports but will not accept is drift nobody can fix.
104
+ */
105
+ export const matches = (attributes: UserAttributes, props: UserProps) =>
106
+ attributes.comment === (props.comment ?? '') &&
107
+ attributes.email === (props.email ?? '') &&
108
+ attributes.enable === (props.enable !== false) &&
109
+ attributes.expire === (props.expire ?? 0) &&
110
+ attributes.firstname === (props.firstname ?? '') &&
111
+ attributes.lastname === (props.lastname ?? '') &&
112
+ attributes.groups.join(',') === groupSet(props.groups).join(',');
113
+
114
+ /**
115
+ * ★ Default `read` role for this family — only the write side needs `provision`.
116
+ * ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
117
+ * kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
118
+ * key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
119
+ * family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
120
+ * JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
121
+ * fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
122
+ * (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
123
+ * which `runPveWith` retries across every member and exhausts identically —
124
+ * `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
125
+ * false `update` on every account.
126
+ */
127
+ export const readUserOrFail = (props: UserProps) =>
128
+ readOrUnreadable(
129
+ runPve(props.target, 'read', false, access.getAccessUser({ userid: props.userid })),
130
+ ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
131
+
132
+ /**
133
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
134
+ * missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
135
+ * "absent" from a failure to ever run — without this fold, a brand-new account could never be
136
+ * created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
137
+ * which PVE refuses loudly rather than silently corrupting anything.
138
+ * ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
139
+ * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
140
+ * nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
141
+ * propagates and fails the whole plan loudly instead.
142
+ */
143
+ export const readUser = (props: UserProps) =>
144
+ readUserOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
145
+
146
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
147
+ export const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
148
+ live === UNREADABLE ? undefined : live;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `Proxmox.User` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
3
+ * `@distilled.cloud/proxmox` protocol — create, drift (including the `groups` array/comma-string
4
+ * asymmetry the header warns about), and delete. adopt-noop.test.ts (via Group) proves the
5
+ * shared "adopting a matching object only reads" property; this file is User's own write paths.
6
+ *
7
+ * ⚠️ A MISSING USER IS A REAL 500, NOT `fakePve`'s USUAL 200-with-null. user.ts's own header
8
+ * measures this, and it matters here: `GetAccessUserResponse` has NO required field, so a
9
+ * 200-with-`{}` (what `fakePve` always answers) decodes as a fully-present, all-default
10
+ * account rather than "absent" — the read would never fail and `create` would never trigger.
11
+ * `missingAs500` below is a small custom stub, not `fake-pve.ts`'s `fakePve`, for that reason.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Layer from 'effect/Layer';
16
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
17
+ import { engineOver } from '../verify/fake-engine.ts';
18
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
19
+ import { ProxmoxUser, ProxmoxUserProvider, type UserProps } from './user.ts';
20
+
21
+ type Live = { comment: string; enable: string; expire: string; groups: readonly string[] };
22
+
23
+ /** `access/users` (POST) and `access/users/{userid}` (GET/PUT), a missing GET answering 500. */
24
+ const missingAs500 = (users: Map<string, Live>) => {
25
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
26
+ const request =
27
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
28
+ const url = new URL(request.url);
29
+ if (url.pathname.includes('/creds/')) {
30
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
31
+ return Response.json({ data, lease_duration: 0 });
32
+ }
33
+ const path = url.pathname.replace(/^\/api2\/json\//, '');
34
+ const match = /^access\/users(?:\/(.+))?$/.exec(path);
35
+ if (match === null) return Response.json({ data: null });
36
+ // ⚠️ distilled percent-encodes a `{userid}` label substitution (`iac@pve` -> `iac%40pve`,
37
+ // MEASURED) where client.ts's plain string concatenation never did. A real PVE decodes it
38
+ // like any HTTP server; this fake must too, or every userid with an `@` "vanishes".
39
+ const userid = match[1] === undefined ? undefined : decodeURIComponent(match[1]);
40
+ const params = new URLSearchParams(await request.text());
41
+ const form = Object.fromEntries(params);
42
+ if (request.method === 'GET' && userid !== undefined) {
43
+ const live = users.get(userid);
44
+ // ⚠️ THE TRAP THIS FILE EXISTS TO PROVE FIXED: measured PVE answers 500, not 200-null.
45
+ if (live === undefined)
46
+ return Response.json({ message: `no such user ('${userid}')` }, { status: 500 });
47
+ return Response.json({ data: { ...live, groups: [...live.groups] } });
48
+ }
49
+ if (request.method === 'POST' && userid === undefined) {
50
+ const { userid: id, ...rest } = form;
51
+ if (id !== undefined) {
52
+ users.set(id, {
53
+ comment: rest['comment'] ?? '',
54
+ enable: rest['enable'] ?? '1',
55
+ expire: rest['expire'] ?? '0',
56
+ groups:
57
+ rest['groups'] === undefined || rest['groups'] === '' ? [] : rest['groups'].split(','),
58
+ });
59
+ }
60
+ return Response.json({ data: null });
61
+ }
62
+ if (request.method === 'PUT' && userid !== undefined) {
63
+ const live = users.get(userid);
64
+ if (live !== undefined) {
65
+ users.set(userid, {
66
+ comment: form['comment'] ?? live.comment,
67
+ enable: form['enable'] ?? live.enable,
68
+ expire: form['expire'] ?? live.expire,
69
+ groups:
70
+ form['groups'] === undefined || form['groups'] === '' ? [] : form['groups'].split(','),
71
+ });
72
+ }
73
+ return Response.json({ data: null });
74
+ }
75
+ if (request.method === 'DELETE' && userid !== undefined) {
76
+ users.delete(userid);
77
+ return Response.json({ data: null });
78
+ }
79
+ return Response.json({ data: null });
80
+ };
81
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
82
+ return {
83
+ layer: FetchHttpClient.layer.pipe(
84
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
85
+ ),
86
+ };
87
+ };
88
+
89
+ const declare = (over: Partial<UserProps> = {}): UserProps => ({
90
+ target: FAKE_TARGET,
91
+ userid: 'iac@pve',
92
+ ...over,
93
+ });
94
+
95
+ describe('Proxmox.User over the distilled protocol', () => {
96
+ test('creating an account that does not exist yet: one POST, defaults sent explicitly', async () => {
97
+ const users = new Map<string, Live>();
98
+ const fake = missingAs500(users);
99
+ await withoutBao(async () => {
100
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
101
+ expect(await engine.deploy(ProxmoxUser('iac', declare()))).toEqual({ iac: 'create' });
102
+ });
103
+ expect(users.get('iac@pve')).toEqual({ comment: '', enable: '1', expire: '0', groups: [] });
104
+ });
105
+
106
+ test('a drifted comment settles, and an unsorted live groups array is not itself drift', async () => {
107
+ const users = new Map<string, Live>([
108
+ ['iac@pve', { comment: 'old', enable: '1', expire: '0', groups: ['b', 'a'] }],
109
+ ]);
110
+ const fake = missingAs500(users);
111
+ await withoutBao(async () => {
112
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
113
+ const declared = () => ProxmoxUser('iac', declare({ comment: 'new', groups: ['a', 'b'] }));
114
+ const report = await engine.verify(declared());
115
+ expect(report.rows[0]).toMatchObject({ changed: ['comment'], diff: 'update' });
116
+ expect(await engine.deploy(declared())).toEqual({ iac: 'adopted' });
117
+ });
118
+ expect(users.get('iac@pve')).toMatchObject({ comment: 'new', groups: ['a', 'b'] });
119
+ });
120
+
121
+ test('deleting removes the account (no retain default for User)', async () => {
122
+ const users = new Map<string, Live>([
123
+ ['iac@pve', { comment: '', enable: '1', expire: '0', groups: [] }],
124
+ ]);
125
+ const fake = fakePve((call: PveCall) => {
126
+ const match = /^access\/users\/(.+)$/.exec(call.path);
127
+ if (match === null) return undefined;
128
+ // ⚠️ decodeURIComponent — see the ⚠️ on `missingAs500` above: distilled percent-encodes it.
129
+ const userid = decodeURIComponent(match[1] as string);
130
+ if (call.method === 'GET') return users.get(userid);
131
+ if (call.method === 'DELETE') users.delete(userid);
132
+ return undefined;
133
+ });
134
+ await withoutBao(async () => {
135
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
136
+ await engine.deploy(ProxmoxUser('iac', declare()));
137
+ expect(await engine.deploy(Effect.void)).toEqual({});
138
+ });
139
+ expect(users.has('iac@pve')).toBe(false);
140
+ expect(fake.writes()).toEqual(['DELETE access/users/iac%40pve']);
141
+ });
142
+ });