@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
@@ -2,17 +2,15 @@
2
2
  * `Proxmox.ApiToken` — a PVE API token: the thing a service actually authenticates as.
3
3
  *
4
4
  * ⛔ THE SECRET EXISTS FOR ONE HTTP RESPONSE AND THEN NOWHERE. MEASURED from the cluster's own
5
- * schema (`/usr/share/pve-docs/api-viewer/apidoc.js` on node-b, 2026-09-13): POST returns
6
- * `["full-tokenid","info","value"]`; PUT returns `["comment","expire","full-tokenid","privsep",
7
- * "value"]` with `value` present ONLY when `regenerate` was set; GET returns
8
- * `["comment","expire","privsep"]` and never the secret. A live token agrees —
9
- * `GET /access/users/metrics@pve/token/exporter` answers `{"expire":0,"privsep":0}`. PVE says
10
- * it plainly on the POST: the value "needs to be stored as it cannot be retrieved afterwards".
5
+ * schema: POST returns `["full-tokenid","info","value"]`; PUT returns `["comment","expire",
6
+ * "full-tokenid","privsep","value"]` with `value` present ONLY when `regenerate` was set; GET
7
+ * returns `["comment","expire","privsep"]` and never the secret. PVE says it plainly on the
8
+ * POST: the value "needs to be stored as it cannot be retrieved afterwards".
11
9
  *
12
10
  * ⛔ SO A TOKEN THIS RESOURCE CREATES IS UNUSABLE, AND SAYING SO IS THE POINT OF THIS BLOCK. The
13
11
  * secret may not become an attribute: Alchemy persists attributes UNENCRYPTED, and this estate's
14
- * state store is a Postgres dumped nightly (the ⛔ in credentials.ts). So `reconcile` drops the
15
- * value on the floor, and what lands is a live credential in `/etc/pve/user.cfg` whose secret
12
+ * state store is a Postgres dumped nightly (the ⛔ in credentials.ts). So `reconcile` refuses
13
+ * before any POST, and what would land is a live credential in `/etc/pve/user.cfg` whose secret
16
14
  * nobody holds. No `regenerate` rescues it, because a regenerated value is discarded the same
17
15
  * way. The only escape is an operator who captured the value out of band — which this provider
18
16
  * gives them no way to do.
@@ -20,50 +18,49 @@
20
18
  * whole of a token's policy, and declaring them is real work: it is how `expire` stops being
21
19
  * whatever somebody typed in 2024. Mint NEW tokens where the secret can be caught — OpenBao's
22
20
  * `proxmox-c1` mount, or a human at `pveum user token add`.
23
- * ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "THE CREATE PATH IS LEFT REACHABLE
24
- * RATHER THAN STUBBED… the honest arrangement is a create that works". THAT CONTRADICTED THE
25
- * `reconcile` BELOW, WHICH REFUSES: `handlers.reconcile` calls `Effect.die` by name when `read`
26
- * answers absent, before any POST — metadata-only per decision 9 (2026-09-23), exported on the
27
- * barrel as an adopt-and-manage-only Resource. A create that silently did nothing WOULD be the
28
- * lie resource.ts's ★ describes for a stubbed delete; refusing loudly, by name, with the two
29
- * ways to get a usable token, is the honest arrangement instead — see the ★ further down and
30
- * api-token-adopt.test.ts's absent-token row, which pins the refusal and zero writes.
31
21
  *
32
22
  * ⛔ THERE IS NO `regenerate` PROP, AND IT IS NOT AN OVERSIGHT. PVE's own description: "All users
33
23
  * of the previous secret will lose access after this operation." A prop for it would revoke a
34
24
  * live credential during a deploy whose plan said `update`, and hand back a replacement this
35
- * provider is obliged to throw away — breakage with no recovery. Rotation belongs to the mount
36
- * that owns the lease, or to a human who is watching.
25
+ * provider is obliged to throw away — breakage with no recovery.
37
26
  *
38
27
  * ⚠️ THERE IS A SECOND WRITER TO THESE OBJECTS AND IT IS THE ONE THIS PROVIDER RUNS ON. OpenBao's
39
- * `proxmox-c1` engine mints PVE tokens under `hf-read@pve` and `hf-provision@pve` — read from
40
- * `<estate>/platform/secrets/vault/plugin-proxmox/proxmox/`: `privsep=0` (client.go), `expire` set
41
- * to the lease deadline, and an id the plugin CHOOSES,
42
- * `hf-<role>-<actor>-<entity6>-<stamp>-<nonce>` (tokenname.go). Revocation and WAL rollback look
43
- * up that one exact id (wal.go); neither sweeps a prefix. So the two systems cannot collide on
44
- * one object by accident — but they share the USER: `hf-read@pve` held three live leases when
45
- * this was written, and `Proxmox.User` deleting that account takes every one of them with it.
46
- * ⛔ DO NOT DECLARE A TOKEN UNDER A MINT USER. `expire` is what makes that dangerous rather than
47
- * untidy: point this resource at a lease token with the wrong expire and a 300-second
48
- * credential becomes a permanent one — and on `hf-provision@pve` that credential carries
49
- * `Permissions.Modify` at `/`. docs/privileges.md records what those 27 privileges buy.
28
+ * `proxmox-c1` engine mints PVE tokens under `hf-read@pve` and `hf-provision@pve` with an id it
29
+ * CHOOSES (`hf-<role>-<actor>-<entity6>-<stamp>-<nonce>`), so the two systems cannot collide on
30
+ * one object by accident — but they share the USER, and `Proxmox.User` deleting that account
31
+ * takes every one of its tokens with it. ⛔ DO NOT DECLARE A TOKEN UNDER A MINT USER: `expire`
32
+ * is what makes that dangerous — a 300-second lease token becomes a permanent one.
50
33
  *
51
34
  * ⚠️ PRIVILEGES, AND THE READ LANE CANNOT DO IT. MEASURED: all four methods on this path check
52
35
  * `['or', ['userid-param','self'], ['userid-group', ['User.Modify']]]` — with no `Sys.Audit`
53
- * alternative, unlike `GET /access/users/{userid}`, which has one. And
54
- * `pvesh get /access/permissions --userid hf-read@pve --path /access/groups` answers the seven
55
- * PVEAuditor audit privileges and no `User.Modify`, while `hf-provision@pve` has it. So the
56
- * 3600s read lease reaches at most its OWN account's tokens and is refused for every other
57
- * userid — which `read` in resource.ts folds into "absent", so the plan says create and the POST
58
- * then fails with "Token already exists". Hence `readRole: 'provision'` below, for the same
59
- * reason storage.ts, sdn-zone.ts and sdn-vnet.ts set it, at the same cost: every plan mints a
60
- * 300s non-renewable provision lease just to read.
36
+ * alternative — so the 3600s read lease reaches at most its OWN account's tokens and is refused
37
+ * for every other userid. `readRole: 'provision'` below, for the same reason storage.ts,
38
+ * sdn-zone.ts and sdn-vnet.ts set it, at the same cost: every plan mints a 300s non-renewable
39
+ * provision lease just to read.
40
+ *
41
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers`/`pveOperations` ONTO
42
+ * `@distilled.cloud/proxmox`'s typed `access.getAccessUserToken`/`putAccessUserToken`/
43
+ * `deleteAccessUserToken` (2026-09-24, decision 43's proxmox walk-down, the last access-family
44
+ * resource). There is no `createAccessUserToken` call anywhere in this file — the create branch
45
+ * was already provably unreachable before the migration (`reconcile` dies on an absent read
46
+ * before any create path runs), so nothing calls distilled's POST-shaped `updateAccessUserToken`
47
+ * operation either; see api-token-form.ts for why the generator gave it that name.
61
48
  */
62
49
  import { Resource } from 'alchemy';
50
+ import { isResolved } from 'alchemy/Diff';
63
51
  import * as Provider from 'alchemy/Provider';
52
+ import * as access from '@distilled.cloud/proxmox/access';
64
53
  import * as Effect from 'effect/Effect';
65
- import { apiTokenSpec } from './api-token-form.ts';
66
- import { type PveRequirements, type WithTarget, pveHandlers, pveOperations } from './resource.ts';
54
+ import { API_TOKEN_UPDATE, attributesOf, matches, shape } from './api-token-form.ts';
55
+ import { guardWrite } from './distilled-guard.ts';
56
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
57
+ import { runPve } from './distilled-pve.ts';
58
+ import {
59
+ UNREADABLE,
60
+ type Unreadable,
61
+ readOrUnreadable,
62
+ unreadableWarning,
63
+ } from './unreadable-read.ts';
67
64
 
68
65
  export interface ApiTokenProps extends WithTarget {
69
66
  /**
@@ -78,57 +75,36 @@ export interface ApiTokenProps extends WithTarget {
78
75
  /**
79
76
  * The token's own name, unique within the account. Identity.
80
77
  *
81
- * ⚠️ PVE HAS NO RENAME, AND HERE THAT COSTS MORE THAN IT DOES FOR A USER. Editing this prop (or
82
- * `userid`) points `path` at a DIFFERENT token, which reads as absent and is then created —
83
- * leaving the old token LIVE, with its secret still working, invisible to the plan, while the
84
- * new one is the unusable kind described at the top. Rename by declaring a delete and a
85
- * create, the way user.ts says for an account.
86
- * ⛔ `diff` IS NOT OVERRIDDEN TO SAY `replace` THE WAY acl.ts DOES, AND THE DIFFERENCE IS THE
87
- * OBJECT. There, replace removes a grant that can be rebuilt from its own tuple; here it
88
- * would DELETE a live token — destroying an irrecoverable secret, and everything using it —
89
- * to tidy up a name. Leaving the old token standing is the lesser harm, and saying so here
90
- * is what keeps it from looking like an omission.
91
- * ⚠️ PATTERN `[A-Za-z][A-Za-z0-9.\-_]+`: a letter first, two characters minimum. The schema
92
- * declares no maxLength; the OpenBao plugin assumes a conservative 64 rather than finding the
93
- * real limit in production, and so should anything else.
78
+ * ⚠️ PVE HAS NO RENAME. Editing this prop (or `userid`) points `path` at a DIFFERENT token,
79
+ * which reads as absent and is then created — leaving the old token LIVE, invisible to the
80
+ * plan, while the new one is the unusable kind described at the top. Rename by declaring a
81
+ * delete and a create.
82
+ * ⛔ `diff` IS NOT OVERRIDDEN TO SAY `replace` THE WAY acl.ts DOES: there, replace removes a
83
+ * grant that can be rebuilt from its own tuple; here it would DELETE a live token —
84
+ * destroying an irrecoverable secret — to tidy up a name. Leaving the old token standing is
85
+ * the lesser harm.
86
+ * ⚠️ PATTERN `[A-Za-z][A-Za-z0-9.\-_]+`: a letter first, two characters minimum.
94
87
  */
95
88
  tokenid: string;
96
- /**
97
- * Free text in `pveum user token list` and the UI. Empty is how it is cleared — see `shape`.
98
- * ⚠️ ANY CHARACTER IS SAFE HERE, MEASURED, and it was worth checking: `user.cfg` is a
99
- * colon-delimited line, so a comment holding a colon or a newline is the obvious place for a
100
- * round-trip to lose a character and diff forever. PVE escapes it — `encode_text` turns
101
- * `a:b\nc%d` into `a%3Ab%0Ac%25d` and `decode_text` gives it back byte for byte.
102
- */
89
+ /** Free text in `pveum user token list` and the UI. Empty is how it is cleared — see `shape`. */
103
90
  comment?: string;
104
91
  /**
105
92
  * Seconds since the epoch, or 0 for "never expires".
106
93
  *
107
94
  * ⛔ REQUIRED, AND THE SCHEMA WILL TELL YOU IT NEED NOT BE. It declares the default "same as
108
95
  * user", and the code never implements it: `generate_token` writes `expire` only when the
109
- * parameter is defined, `user.cfg` stores a token as `token:<id>:<expire>:<privsep>:<comment>:`
110
- * with no absent state, and the parser does `$expire = 0 if !$expire` (AccessControl.pm:1624).
111
- * MEASURED end to end: every token on this cluster that was created without an expire reads
112
- * back `"expire":0`. So "same as user" is documentation of an intention, not behaviour.
113
- * ⛔ WHICH IS WHY IT IS NOT OPTIONAL-DEFAULTING-TO-0. That spelling reads identically and is far
114
- * worse: an omitted `expire` would then quietly WRITE 0 over a live deadline, turning an
115
- * expiring credential into a permanent one, and the plan would call it an update. Requiring
116
- * the field makes "this token never expires" a sentence somebody had to type.
96
+ * parameter is defined, and the parser does `$expire = 0 if !$expire`. Required, so "this
97
+ * token never expires" is a sentence somebody had to type, rather than an omission that would
98
+ * quietly WRITE 0 over a live deadline.
117
99
  */
118
100
  expire: number;
119
101
  /**
120
102
  * True keeps the token's privileges separate from its owner's — it gets NOTHING until an ACL
121
103
  * names `fullTokenid`. False gives it the owner's privileges entire.
122
104
  *
123
- * ⛔ REQUIRED FOR THE SAME REASON AS `expire`, AND THE BLAST RADIUS IS LARGER. PVE's API default
124
- * is 1, every token on this cluster is 0 (measured), and `user.cfg` materialises it either way
125
- * — `$privsep = $privsep ? 1 : 0` (AccessControl.pm:1622), so there is no unset state to
126
- * preserve. Were this optional, adopting a live token without mentioning `privsep` would plan
127
- * an update to 1 and STRIP a working credential of every privilege it has, silently: PVE
128
- * returns 401/403 to the service, nothing errors here, and `iac@pve!apply` simply stops
129
- * working. Required, so adopting a token is a sentence that states what it is.
130
- * ⚠️ AND `false` IS NOT A SHRUG. It is a token with its owner's whole privilege set; under a
131
- * provisioning account that is the account's full authority with a separate secret.
105
+ * ⛔ REQUIRED FOR THE SAME REASON AS `expire`, AND THE BLAST RADIUS IS LARGER. Were this
106
+ * optional, adopting a live token without mentioning `privsep` would plan an update to 1 and
107
+ * STRIP a working credential of every privilege it has, silently.
132
108
  */
133
109
  privsep: boolean;
134
110
  }
@@ -143,9 +119,7 @@ export interface ApiTokenAttributes {
143
119
  privsep: boolean;
144
120
  /**
145
121
  * ⛔ THERE IS NO `value` FIELD HERE AND THERE NEVER MAY BE. Adding one would write a working PVE
146
- * credential, in clear, into the state Postgres and into every nightly dump of it — the exact
147
- * leak metric-server.ts types its `token` as `never` to prevent. The read cannot supply one
148
- * anyway; only create and regenerate can, and both of those responses are dropped.
122
+ * credential, in clear, into the state Postgres and into every nightly dump of it.
149
123
  */
150
124
  }
151
125
 
@@ -159,69 +133,109 @@ export interface ProxmoxApiToken extends Resource<
159
133
 
160
134
  /**
161
135
  * ★ `retain` BY DEFAULT, AND THIS IS THE PLAINEST CASE FOR IT IN THE PACKAGE. A token's contents
162
- * are one irreplaceable secret: delete it and the value is gone, a replacement is a DIFFERENT
163
- * value, and every holder loses access the instant `cfs_write_file` returns — with no error
164
- * raised anywhere near them. The live cluster's tokens are `metrics@pve!exporter` (the PVE
165
- * exporter feeding VictoriaMetrics), `agent@pve!executor`, `iac@pve!apply`, `iac@pve!ro`,
166
- * `app@pve!app` and `mint@pve!engine` — the last being the parent credential the
167
- * OpenBao mount itself authenticates with, so orphaning that one would stop every plan in this
168
- * package, this resource included. `delete` is FULLY IMPLEMENTED (DELETE is a real method on
169
- * this path, measured) and runs the moment a caller opts in with `.pipe(RemovalPolicy.destroy())`.
170
- * See the ★ in resource.ts, which explains the convention once.
136
+ * are one irreplaceable secret: delete it and the value is gone, and every holder loses access
137
+ * the instant `cfs_write_file` returns — with no error raised anywhere near them. `delete` is
138
+ * FULLY IMPLEMENTED and runs the moment a caller opts in with `.pipe(RemovalPolicy.destroy())`.
171
139
  */
172
140
  export const ProxmoxApiToken = Resource<ProxmoxApiToken>('Proxmox.ApiToken', {
173
141
  defaultRemovalPolicy: 'retain',
174
142
  });
175
143
 
176
- const ops = pveOperations(apiTokenSpec);
144
+ /** ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges. */
145
+ const readToken = (props: ApiTokenProps) =>
146
+ readOrUnreadable(runPve(props.target, 'provision', false, access.getAccessUserToken(props))).pipe(
147
+ Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
148
+ Effect.orElseSucceed(() => undefined),
149
+ );
177
150
 
178
- /**
179
- * ⛔ THIS FAMILY ADOPTS AND MANAGES TOKENS; IT REFUSES TO MINT ONE, AND THAT IS A DECISION RATHER
180
- * THAN A GAP. The secret exists ONLY in the create response — MEASURED from the schema: `POST`
181
- * returns `["full-tokenid","info","value"]`, where `value` is "API token value used for
182
- * authentication", while `GET` returns `["comment","expire","privsep"]` and never the secret.
183
- * It may not become an attribute, because Alchemy writes attributes to its state store
184
- * UNENCRYPTED into a Postgres that is dumped nightly.
185
- *
186
- * So a token created here would be A LIVE CREDENTIAL ON THE CLUSTER THAT NOBODY HOLDS: valid,
187
- * privileged, unusable, and indistinguishable from one somebody meant to keep. Regenerating does
188
- * not rescue it — a `regenerate` PUT returns the new value down the same discarded path. So
189
- * `reconcile` refuses by name and says what to do instead.
190
- *
191
- * ★ EVERYTHING ELSE STILL WORKS, AND IT IS THE HALF WORTH HAVING. Adopting an existing token and
192
- * converging its `comment`, `expire` and `privsep` are real operations: C1 carries SEVEN tokens
193
- * with `expire=0` and `privsep=0` made by clicks nobody recorded. Declaring those freezes the
194
- * set, and an eighth appearing shows up as drift.
195
- *
196
- * ⚠️ IF DECLARATIVE MINTING IS EVER WANTED, the missing piece is a secret SINK — write the value
197
- * straight into an OpenBao kv path and return only its address. That is a different resource
198
- * with a different contract; it must not be bolted onto this one.
199
- */
200
- const handlers = {
201
- ...pveHandlers(apiTokenSpec),
202
- reconcile: Effect.fn(function* ({ news }: { news: ApiTokenProps }) {
203
- const live = yield* ops.read(news);
204
- if (live === undefined) {
205
- return yield* Effect.die(
206
- new Error(
207
- `${news.userid}!${news.tokenid}: this resource does not create API tokens. PVE returns ` +
208
- 'the secret only in the create response and it cannot be stored, so a token made here ' +
209
- 'would be a live credential nobody holds. Create it with `pveum user token add` and ' +
210
- 'capture the value, or mint a short-lived one from the OpenBao proxmox mount ' +
211
- '(`bao read proxmox-c1/creds/<role>`) -- then declare it here to manage it.',
212
- ),
213
- );
214
- }
215
- return yield* ops.reconcile(news);
216
- }),
217
- };
151
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
152
+ const dropUnreadable = (live: ApiTokenAttributes | Unreadable | undefined) =>
153
+ live === UNREADABLE ? undefined : live;
218
154
 
219
155
  /**
220
- * ⛔ `list` IS EMPTY, AND FOR THIS FAMILY THAT MATTERS MORE THAN THE GENERIC ARGUMENT IN
221
- * resource.ts. A token index handed to Alchemy would offer up `mint@pve!engine` and every
222
- * live OpenBao lease for adoption — and adoption is what makes a later plan willing to delete.
223
- * The mount's leases in particular appear and vanish on their own; anything that adopted one
224
- * would report drift against a credential that was never its to hold.
156
+ * ⛔ THIS FAMILY ADOPTS AND MANAGES TOKENS; IT REFUSES TO MINT ONE — see the header. `reconcile`
157
+ * refuses by name before any write when the read finds nothing, so the create path this
158
+ * family's spec would otherwise need never runs, and no `createAccessUserToken`-shaped call
159
+ * exists in this file at all.
225
160
  */
226
161
  export const ProxmoxApiTokenProvider = () =>
227
- Provider.effect(ProxmoxApiToken, Effect.succeed(ProxmoxApiToken.Provider.of(handlers)));
162
+ Provider.effect(
163
+ ProxmoxApiToken,
164
+ Effect.succeed(
165
+ ProxmoxApiToken.Provider.of({
166
+ /**
167
+ * ⛔ `list` IS EMPTY, AND FOR THIS FAMILY THAT MATTERS MORE THAN THE GENERIC ARGUMENT.
168
+ * A token index handed to Alchemy would offer up `mint@pve!engine` and every live
169
+ * OpenBao lease for adoption — and adoption is what makes a later plan willing to delete.
170
+ */
171
+ list: () => Effect.succeed([]),
172
+ read: Effect.fn(function* ({ olds }) {
173
+ return dropUnreadable(yield* readToken(olds));
174
+ }),
175
+ diff: Effect.fn(function* ({ news, output }) {
176
+ if (!isResolved(news)) return undefined;
177
+ yield* guardWrite(API_TOKEN_UPDATE, shape(news), false);
178
+ if (output === undefined) return undefined;
179
+ const live = yield* readToken(news);
180
+ // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
181
+ // `update` on a token that was plainly there — see unreadable-read.ts.
182
+ if (live === UNREADABLE) {
183
+ yield* unreadableWarning('Proxmox.ApiToken', `${news.userid}!${news.tokenid}`);
184
+ return { action: 'noop' } as const;
185
+ }
186
+ // ⚠️ NO GUARDED-CREATE BRANCH HERE, UNLIKE EVERY OTHER FAMILY IN THIS SUB-AREA. There
187
+ // is nothing to guard a create form for — `reconcile` refuses instead, and `diff`
188
+ // answering `update` for an absent token is what routes a real deploy into that
189
+ // refusal rather than silently reporting `noop` over a token that never landed.
190
+ return live !== undefined && matches(live, news)
191
+ ? ({ action: 'noop' } as const)
192
+ : ({ action: 'update' } as const);
193
+ }),
194
+ reconcile: Effect.fn(function* ({ news }) {
195
+ // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
196
+ // read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race —
197
+ // but UNLIKE group/role/user, the message below assumes genuine absence and would
198
+ // misname a transient credential refusal as "this resource does not create tokens".
199
+ // Rare (the SAME role must mint twice, once for diff/once for reconcile, and only the
200
+ // second is denied) and not silently wrong — the operator still sees a die() and goes
201
+ // looking — but named here rather than left for the next reader to assume away.
202
+ const before = dropUnreadable(yield* readToken(news));
203
+ if (before === undefined) {
204
+ return yield* Effect.die(
205
+ new Error(
206
+ `${news.userid}!${news.tokenid}: this resource does not create API tokens. PVE ` +
207
+ 'returns the secret only in the create response and it cannot be stored, so a ' +
208
+ 'token made here would be a live credential nobody holds. Create it with ' +
209
+ '`pveum user token add` and capture the value, or mint a short-lived one from ' +
210
+ 'the OpenBao proxmox mount (`bao read proxmox-c1/creds/<role>`) -- then declare ' +
211
+ 'it here to manage it.',
212
+ ),
213
+ );
214
+ }
215
+ yield* guardWrite(API_TOKEN_UPDATE, shape(news), false);
216
+ if (!matches(before, news)) {
217
+ yield* runPve(news.target, 'provision', true, access.putAccessUserToken(shape(news)));
218
+ }
219
+ const after = dropUnreadable(yield* readToken(news));
220
+ if (after === undefined) {
221
+ return yield* Effect.die(
222
+ new Error(
223
+ `${news.userid}!${news.tokenid}: the write returned no error but the token is ` +
224
+ 'still absent. PVE wraps every answer in {"data":...} and can report success ' +
225
+ 'on a call that did nothing -- read back rather than trusting the status code.',
226
+ ),
227
+ );
228
+ }
229
+ return after;
230
+ }),
231
+ delete: Effect.fn(function* ({ olds }) {
232
+ yield* runPve(
233
+ olds.target,
234
+ 'provision',
235
+ true,
236
+ access.deleteAccessUserToken({ tokenid: olds.tokenid, userid: olds.userid }),
237
+ );
238
+ }),
239
+ }),
240
+ ),
241
+ );
@@ -15,14 +15,9 @@
15
15
  import * as Effect from 'effect/Effect';
16
16
  import * as HttpClient from 'effect/unstable/http/HttpClient';
17
17
  import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
18
- import {
19
- type ApiTarget,
20
- type PveCredential,
21
- type PveRole,
22
- type PveTarget,
23
- authorization,
24
- } from './credentials.ts';
18
+ import type { ApiTarget, PveCredential, PveRole, PveTarget } from './credentials.ts';
25
19
  import { leased } from './lease-cache.ts';
20
+ import { authorization } from './mint.ts';
26
21
  import { executeOnCluster } from './members.ts';
27
22
 
28
23
  export class PveError extends Error {
@@ -16,7 +16,6 @@
16
16
  * there).
17
17
  */
18
18
  import { describe, expect, test } from 'bun:test';
19
- import { shape as apiTokenForm } from './api-token-form.ts';
20
19
  import { NETWORK_APPLY_ENDPOINT, SDN_APPLY_ENDPOINT } from './apply-endpoints.ts';
21
20
  import { constraintsFor, formViolations } from './constraint-guard.ts';
22
21
  import type { PveTarget } from './credentials.ts';
@@ -30,32 +29,13 @@ const TARGET: PveTarget = { members: ['pve.test:8006'], mount: 'pve-test', schem
30
29
  const onCreate = (key: string, form: Record<string, string | readonly string[]>) =>
31
30
  formViolations(key, form, true);
32
31
 
33
- describe('Proxmox.ApiToken plans clean against POST /access/users/{userid}/token/{tokenid}', () => {
34
- /** ⚠️ `expire: 0` is "never", and PVE's `minimum: 0` accepts it — the bound the table carries. */
35
- test('the estate-shaped token declaration has no violations', () => {
36
- expect(
37
- onCreate(
38
- 'pve:POST /access/users/{userid}/token/{tokenid}',
39
- apiTokenForm({
40
- comment: 'Declared by Alchemy',
41
- expire: 0,
42
- privsep: false,
43
- target: TARGET,
44
- tokenid: 'apply',
45
- userid: 'iac@pve',
46
- }),
47
- ),
48
- ).toEqual([]);
49
- });
50
-
51
- /** ⛔ `{userid}` AND `{tokenid}` ARE PATH SEGMENTS. A table that kept them would refuse every
52
- * create for a "missing" parameter that was never missing (codegen/emit.ts). */
53
- test('neither path parameter is in the table', () => {
54
- const table = constraintsFor('pve:POST /access/users/{userid}/token/{tokenid}');
55
- expect(table['userid']).toBeUndefined();
56
- expect(table['tokenid']).toBeUndefined();
57
- });
58
- });
32
+ // ⛔ Proxmox.ApiToken's own create-endpoint check was REMOVED HERE 2026-09-24, not merely
33
+ // updated: the distilled migration (api-token.ts) confirmed the create path was already
34
+ // unreachable (reconcile refuses before any POST — see that file's header) and stopped
35
+ // guarding a create form at all, so `'pve:POST /access/users/{userid}/token/{tokenid}'` is no
36
+ // longer named anywhere in this package's source. `codegen/constraints.ts` (its own `keysFromSource`)
37
+ // dropped the table for it on the next regeneration — there is nothing left here to test
38
+ // against, and keeping this block would fail with "no vendor constraint table" forever.
59
39
 
60
40
  describe('Proxmox.MetricServer plans clean against POST /cluster/metrics/server/{id}', () => {
61
41
  /**
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `credentials.ts`'s one typed mint failure (S21) — split out to keep that file under the
3
+ * 250-line cap once this class grew a real header (2026-09-24, the cries-wolf fix).
4
+ */
5
+ import * as Data from 'effect/Data';
6
+ import type { PveRole } from './credentials.ts';
7
+
8
+ /**
9
+ * OpenBao answered 403 to `GET /v1/<mount>/creds/<role>`: this identity's AppRole has no grant
10
+ * for `role` on `mount` — measured 2026-09-24 as "the agent lane cannot mint `provision`" (the
11
+ * lane's policy grants `read` but not `provision`). A TYPED tag, not the plain `Error` every
12
+ * other mint failure still is (404 no such role/mount, 503 sealed, a network failure), because
13
+ * this ONE case means "refused, not evidence of anything about the object" — a family's
14
+ * `readOrUnreadable` (unreadable-read.ts) `catchTag`s exactly this, never any other mint
15
+ * failure, which stays a hard error precisely because it is NOT a scoped refusal.
16
+ *
17
+ * ⛔ WHY NOT EVERY NON-2xx: A 503 (sealed) or a 404 (misconfigured mount/role) is the estate
18
+ * BROKEN, not this identity being narrowly scoped — folding those into "unreadable, report
19
+ * noop" would hide a real outage behind a quiet, misleadingly reassuring plan. Only 403 is
20
+ * "this identity, this role, denied" and safe to read as "cannot see, not evidence of absence".
21
+ */
22
+ export class PveCredentialDenied extends Data.TaggedError('PveCredentialDenied')<{
23
+ readonly mount: string;
24
+ readonly role: PveRole;
25
+ readonly detail: string;
26
+ }> {
27
+ override get message(): string {
28
+ return (
29
+ `OpenBao GET /v1/${this.mount}/creds/${this.role} -> 403: ${this.detail}. This identity's ` +
30
+ 'AppRole has no grant for this role -- a refused read, not evidence the object is gone.'
31
+ );
32
+ }
33
+ }
@@ -12,7 +12,8 @@ import { join } from 'node:path';
12
12
  import { describe, it } from 'node:test';
13
13
  import * as Effect from 'effect/Effect';
14
14
  import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
- import { type BaoEnvironment, type PveTarget, mint } from './credentials.ts';
15
+ import type { BaoEnvironment, PveTarget } from './credentials.ts';
16
+ import { mint } from './mint.ts';
16
17
 
17
18
  /**
18
19
  * ⚠️ THIS PACKAGE'S tsconfig CARRIES NO BUN TYPES, and its package.json is out of bounds for this
@@ -100,8 +101,8 @@ describe('mint', () => {
100
101
  });
101
102
  });
102
103
 
103
- it('fails 403, 404 and 503 with OpenBao errors, never the token', async () => {
104
- for (const status of [403, 404, 503]) {
104
+ it('fails 404 and 503 with OpenBao errors, never the token', async () => {
105
+ for (const status of [404, 503]) {
105
106
  await withFake(
106
107
  { json: { errors: [`status ${String(status)}`] }, status },
107
108
  async (address) => {
@@ -117,6 +118,31 @@ describe('mint', () => {
117
118
  }
118
119
  });
119
120
 
121
+ // ★ 403 IS THE ONE STATUS WITH ITS OWN TAG (credential-errors.ts) — a family's
122
+ // `readOrUnreadable` (unreadable-read.ts) `catchTag`s exactly this, never 404/503 above.
123
+ it('fails 403 as the typed PveCredentialDenied, never the token', async () => {
124
+ await withFake({ json: { errors: ['status 403'] }, status: 403 }, async (address) => {
125
+ const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
126
+ assert.ok(error instanceof Error);
127
+ assert.equal((error as { _tag?: string })._tag, 'PveCredentialDenied');
128
+ assert.match(error.message, /-> 403: status 403\. This identity's AppRole has no grant/);
129
+ assert.ok(!error.message.includes(TOKEN));
130
+ });
131
+ });
132
+
133
+ // ⛔ FOUND ON ADVERSARIAL REVIEW 2026-09-24: status alone is not enough. A WAF or a proxy in
134
+ // front of OpenBao can answer 403 without OpenBao ever seeing the request — folding THAT into
135
+ // `PveCredentialDenied` would hide a real outage behind the same "noop, nothing to worry
136
+ // about" reading the cries-wolf fix exists to stop, only for a different cause.
137
+ it('a 403 with no OpenBao error body stays the untyped refusal, not PveCredentialDenied', async () => {
138
+ await withFake({ json: { message: 'Forbidden' }, status: 403 }, async (address) => {
139
+ const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
140
+ assert.ok(error instanceof Error);
141
+ assert.equal((error as { _tag?: string })._tag, undefined);
142
+ assert.match(error.message, /-> 403: not an OpenBao error body/);
143
+ });
144
+ });
145
+
120
146
  it('refuses a 200 with no token_id, without quoting the secret', async () => {
121
147
  await withFake({ json: { data: { secret: SECRET } }, status: 200 }, async (address) => {
122
148
  const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });