@homeflare/alchemy 0.41.0 → 0.43.0

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 (115) hide show
  1. package/dist/litellm/budget-errors.d.ts +36 -0
  2. package/dist/litellm/budget-errors.d.ts.map +1 -0
  3. package/dist/litellm/budget-form.d.ts +50 -0
  4. package/dist/litellm/budget-form.d.ts.map +1 -0
  5. package/dist/litellm/budget-operations.d.ts +20 -0
  6. package/dist/litellm/budget-operations.d.ts.map +1 -0
  7. package/dist/litellm/budget.d.ts +61 -0
  8. package/dist/litellm/budget.d.ts.map +1 -0
  9. package/dist/litellm/fake-litellm.d.ts +5 -0
  10. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  11. package/dist/litellm/index.d.ts +2 -0
  12. package/dist/litellm/index.d.ts.map +1 -1
  13. package/dist/litellm/index.js +125 -2
  14. package/dist/litellm/index.js.map +9 -5
  15. package/dist/litellm/operations.d.ts +22 -0
  16. package/dist/litellm/operations.d.ts.map +1 -1
  17. package/dist/litellm/providers.d.ts +1 -1
  18. package/dist/litellm/providers.d.ts.map +1 -1
  19. package/dist/openbao/auth-role-form.d.ts +9 -0
  20. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  21. package/dist/openbao/auth-role-wire.d.ts.map +1 -1
  22. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  23. package/dist/openbao/index.js +9 -6
  24. package/dist/openbao/index.js.map +4 -4
  25. package/dist/postgres/connection.d.ts +20 -2
  26. package/dist/postgres/connection.d.ts.map +1 -1
  27. package/dist/postgres/database-sql.d.ts +1 -1
  28. package/dist/postgres/database-sql.d.ts.map +1 -1
  29. package/dist/postgres/database.d.ts +1 -1
  30. package/dist/postgres/database.d.ts.map +1 -1
  31. package/dist/postgres/index.d.ts +5 -3
  32. package/dist/postgres/index.d.ts.map +1 -1
  33. package/dist/postgres/index.js +124 -38
  34. package/dist/postgres/index.js.map +8 -7
  35. package/dist/postgres/providers.d.ts +4 -2
  36. package/dist/postgres/providers.d.ts.map +1 -1
  37. package/dist/postgres/psql-executor.d.ts +21 -0
  38. package/dist/postgres/psql-executor.d.ts.map +1 -0
  39. package/dist/proxmox/acl.d.ts.map +1 -1
  40. package/dist/proxmox/api-token.d.ts.map +1 -1
  41. package/dist/proxmox/ceph-flag-types.d.ts +57 -0
  42. package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
  43. package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
  44. package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
  45. package/dist/proxmox/ceph-flag.d.ts +15 -50
  46. package/dist/proxmox/ceph-flag.d.ts.map +1 -1
  47. package/dist/proxmox/ceph-osd-write.d.ts +10 -13
  48. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  49. package/dist/proxmox/credential-errors.d.ts +18 -4
  50. package/dist/proxmox/credential-errors.d.ts.map +1 -1
  51. package/dist/proxmox/credentials.d.ts +35 -1
  52. package/dist/proxmox/credentials.d.ts.map +1 -1
  53. package/dist/proxmox/group.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +805 -757
  55. package/dist/proxmox/index.js.map +21 -19
  56. package/dist/proxmox/lease-cache.d.ts +67 -5
  57. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  58. package/dist/proxmox/mint.d.ts +4 -2
  59. package/dist/proxmox/mint.d.ts.map +1 -1
  60. package/dist/proxmox/node-network.d.ts.map +1 -1
  61. package/dist/proxmox/role.d.ts.map +1 -1
  62. package/dist/proxmox/storage.d.ts.map +1 -1
  63. package/dist/proxmox/unreadable-read.d.ts +8 -1
  64. package/dist/proxmox/unreadable-read.d.ts.map +1 -1
  65. package/dist/proxmox/user.d.ts.map +1 -1
  66. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  67. package/docs/credentials.md +14 -1
  68. package/docs/litellm-budget.md +55 -0
  69. package/docs/litellm.md +7 -6
  70. package/docs/postgres.md +22 -0
  71. package/docs/proxmox-sdk-migration.md +45 -0
  72. package/package.json +3 -3
  73. package/src/litellm/budget-errors.ts +38 -0
  74. package/src/litellm/budget-form.ts +94 -0
  75. package/src/litellm/budget-operations.ts +51 -0
  76. package/src/litellm/budget.test.ts +105 -0
  77. package/src/litellm/budget.ts +127 -0
  78. package/src/litellm/fake-litellm.ts +50 -1
  79. package/src/litellm/index.ts +13 -0
  80. package/src/litellm/operations.ts +1 -1
  81. package/src/litellm/providers.ts +4 -1
  82. package/src/openbao/auth-role-form.test.ts +37 -0
  83. package/src/openbao/auth-role-form.ts +12 -1
  84. package/src/openbao/auth-role-wire.test.ts +1 -0
  85. package/src/openbao/auth-role-wire.ts +13 -1
  86. package/src/openbao/distilled-retry.test.ts +1 -0
  87. package/src/openbao/fake-engines-roles.ts +7 -1
  88. package/src/postgres/connection.ts +37 -3
  89. package/src/postgres/database-sql.ts +4 -3
  90. package/src/postgres/database.ts +3 -2
  91. package/src/postgres/index.ts +10 -3
  92. package/src/postgres/providers.ts +15 -2
  93. package/src/postgres/psql-executor.test.ts +106 -0
  94. package/src/postgres/psql-executor.ts +130 -0
  95. package/src/proxmox/acl.ts +7 -1
  96. package/src/proxmox/api-token.ts +7 -1
  97. package/src/proxmox/ceph-flag-types.ts +70 -0
  98. package/src/proxmox/ceph-flag-wire.test.ts +72 -0
  99. package/src/proxmox/ceph-flag-wire.ts +123 -0
  100. package/src/proxmox/ceph-flag.ts +77 -130
  101. package/src/proxmox/ceph-osd-write.ts +10 -13
  102. package/src/proxmox/credential-errors.ts +19 -5
  103. package/src/proxmox/credentials.test.ts +73 -1
  104. package/src/proxmox/credentials.ts +72 -1
  105. package/src/proxmox/group.ts +7 -1
  106. package/src/proxmox/lease-cache-mint.test.ts +70 -0
  107. package/src/proxmox/lease-cache.test.ts +83 -7
  108. package/src/proxmox/lease-cache.ts +74 -10
  109. package/src/proxmox/mint.ts +12 -5
  110. package/src/proxmox/node-network.ts +7 -1
  111. package/src/proxmox/role.ts +7 -1
  112. package/src/proxmox/storage.ts +7 -1
  113. package/src/proxmox/unreadable-read.ts +16 -4
  114. package/src/proxmox/user.ts +7 -1
  115. package/src/proxmox/zfs-pool.ts +7 -1
@@ -73,73 +73,24 @@
73
73
  * "Permission check failed (/, Sys.Modify)" on the first deploy, and widen deliberately.
74
74
  */
75
75
  import { Resource } from 'alchemy';
76
+ import { isResolved } from 'alchemy/Diff';
76
77
  import * as Provider from 'alchemy/Provider';
77
78
  import * as Effect from 'effect/Effect';
78
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
79
- import { bool, flag } from './values.ts';
79
+ import {
80
+ CEPH_FLAG_UPDATE,
81
+ readClusterCephFlag,
82
+ readClusterCephFlagForDiff,
83
+ updateForm,
84
+ writeClusterCephFlag,
85
+ } from './ceph-flag-wire.ts';
86
+ import { mintTier } from './credentials.ts';
87
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
88
+ import { guardWrite } from './distilled-guard.ts';
89
+ import type { CephFlagAttributes, CephFlagProps } from './ceph-flag-types.ts';
90
+ import type { PveRequirements } from './resource-spec.ts';
91
+ import { formToSend } from './update-guard.ts';
80
92
 
81
- /**
82
- * PVE's eleven flags, spelled as `PVE::Ceph::Tools::get_possible_osd_flags` spells them.
83
- *
84
- * ⚠️ THE ENUM IS CLOSED AND A TYPO IS A 400, WHICH IS WHY THIS IS A UNION AND NOT `string`. Both
85
- * the GET and the PUT declare `additionalProperties => 0` over exactly this list.
86
- * ⚠️ CEPH HAS FLAGS PVE DOES NOT MODEL, and four of them are always on. MEASURED on C1:
87
- * `ceph osd dump` reports `flags sortbitwise,recovery_deletes,purged_snapdirs,pglog_hardlimit`.
88
- * PVE reads that same string and answers only about its own eleven, so the others can neither
89
- * leak in here nor be set from here — `noautoscale` and `nosnaptrim` included.
90
- * ⛔ IF SOMEBODY LATER "IMPROVES" THE READ BY PARSING `osd dump` DIRECTLY, those four become
91
- * permanently-set flags that nothing declares: a forever-diff on a brand new cluster.
92
- */
93
- export type CephFlagName =
94
- | 'nobackfill'
95
- | 'nodeep-scrub'
96
- | 'nodown'
97
- | 'noin'
98
- | 'noout'
99
- | 'norebalance'
100
- | 'norecover'
101
- | 'noscrub'
102
- | 'notieragent'
103
- | 'noup'
104
- | 'pause';
105
-
106
- export interface CephFlagProps extends WithTarget {
107
- /**
108
- * Which flag. PVE's primary key here, and the last segment of the path.
109
- *
110
- * ⚠️ EDITING IT IN PLACE ORPHANS THE OLD FLAG RATHER THAN MOVING ANYTHING. `diff` reads the NEW
111
- * path and reconcile writes it, while the old flag keeps whatever this stack last put there.
112
- * There is deliberately no replace override for it — acl.ts needs one because its `delete`
113
- * removes a real grant, and `delete` here is inert by design, so a replace would do exactly
114
- * what an update already does. Declare a second resource and set the old one to `value: false`
115
- * rather than renaming this one.
116
- * ⛔ TWO RESOURCES DECLARING THE SAME FLAG ARE ONE CLUSTER OBJECT, and Alchemy sees two ids
117
- * rather than a collision — the acl.ts hazard exactly. Disagreeing, they take turns winning
118
- * and BOTH plan `update` for ever; agreeing, deleting either leaves the flag where the other
119
- * put it. One declaration per flag per cluster.
120
- */
121
- flag: CephFlagName;
122
- /**
123
- * Set the flag (`true`) or clear it (`false`).
124
- *
125
- * ⛔ REQUIRED, WITH NO DEFAULT — see the second ⛔ in the header, which is the whole safety
126
- * argument for this family. It is also the only field PVE accepts on this endpoint, and it is
127
- * not optional in the schema either: omitting it from the form is a 400, not an untouched
128
- * flag. (The BULK endpoint is the one where omission means "leave it alone"; this is not it.)
129
- */
130
- value: boolean;
131
- }
132
-
133
- export interface CephFlagAttributes {
134
- /**
135
- * ⚠️ REPORTED, NEVER COMPARED. It is the path key the read was made WITH, copied back out of
136
- * props, so comparing it against props would be true by construction — the same reasoning
137
- * acl.ts gives for the four identity fields it also declines to diff.
138
- */
139
- flag: CephFlagName;
140
- /** Whether ceph has the flag set right now. The only field `matches` looks at. */
141
- value: boolean;
142
- }
93
+ export type { CephFlagAttributes, CephFlagName, CephFlagProps } from './ceph-flag-types.ts';
143
94
 
144
95
  export interface ProxmoxCephFlag extends Resource<
145
96
  'Proxmox.CephFlag',
@@ -151,86 +102,82 @@ export interface ProxmoxCephFlag extends Resource<
151
102
 
152
103
  export const ProxmoxCephFlag = Resource<ProxmoxCephFlag>('Proxmox.CephFlag');
153
104
 
154
- const handlers = pveHandlers<CephFlagProps, CephFlagAttributes>({
155
- /**
156
- * ⚠️ `live` IS A BARE BOOLEAN HERE, NOT AN OBJECT, AND IT IS HANDED TO `bool` WHOLE ON PURPOSE.
157
- * `get_flag` returns perl `1` or `0` under a `type => 'boolean'` return schema, so the wire is
158
- * `{"data":0}` or `{"data":false}` depending on how the REST layer renders it. `bool` accepts
159
- * every one of those spellings, which is exactly why it is a shared coercion. The factory
160
- * types this parameter `Record<string, unknown>` because every OTHER PVE read is an object;
161
- * reaching for `live['value']` here would answer undefined on every plan and diff `false`
162
- * forever against a set flag.
163
- * ⚠️ THE SCALAR IS A MEASURED PROPERTY OF THIS PVE, NOT A PROMISE. A later version wrapping
164
- * the answer in an object would make `bool` read a SET flag as false, and `value: false`
165
- * would then plan `noop` over a live flag — silently. Re-measure
166
- * `GET /cluster/ceph/flags/noout` after a major upgrade; cheaper than a second parse path.
167
- *
168
- * ⛔ IT NEVER ANSWERS undefined, WHICH MAKES THE CREATE BRANCH UNREACHABLE, AND ITS ERROR WILL
169
- * MISLEAD YOU. All eleven flags always exist; there is nothing for "absent" to mean. So a POST
170
- * to `collection` means the READ failed — an expired 300s lease, node-b down, or ceph simply not
171
- * configured on the cluster (every one of these four handlers opens with
172
- * `check_ceph_configured()`, which dies). Read the resulting "Method 'POST /cluster/ceph/flags'
173
- * not implemented" as "the read failed" and go and look at the credential, not at ceph. Same
174
- * shape as the unreachable POST in acl.ts, same advice.
175
- */
176
- attributes: (live, props) => ({ flag: props.flag, value: bool(live) }),
177
- collection: () => 'cluster/ceph/flags',
178
- /**
179
- * ⛔ UNREACHABLE — see the second ⛔ in `attributes`. It is written in the BULK endpoint's shape
180
- * (the flag name is the parameter, not `value`) rather than left empty, because that is the
181
- * only body `cluster/ceph/flags` has ever documented. Nothing sends it today.
182
- */
183
- createForm: (props) => ({ [props.flag]: flag(props.value) ?? '0' }),
184
- endpoint: { update: 'pve:PUT /cluster/ceph/flags/{flag}' }, // ⛔ No create: see above.
185
- /**
186
- * ⚠️ `value` IS THE ONLY THING COMPARED, AND THE LIST OF THINGS DELIBERATELY NOT COMPARED IS THE
187
- * point of this resource. `flag` is props-derived (see `CephFlagAttributes`). `description`
188
- * and `name` exist only on the bulk GET, which this file does not read, so neither can be
189
- * compared by accident. Nothing PVE returns here is rewritten, re-ordered or re-typed by the
190
- * cluster, because all PVE returns here is one boolean.
191
- * ★ MEASURED: on C1, `GET /cluster/ceph/flags` answers value 0 for all eleven flags, so a
192
- * declaration of `value: false` reads back false and plans `noop`.
193
- */
194
- matches: (attributes, props) => attributes.value === props.value,
195
- path: (props) => `cluster/ceph/flags/${props.flag}`,
196
- /**
197
- * ⚠️ ONLY `value` GOES IN THE BODY. `flag` is already bound by the path and the PUT declares
198
- * `additionalProperties => 0`, so a second copy in the form could only disagree with the URL.
199
- * ⚠️ THE `?? '0'` IS UNREACHABLE, AND IS WRITTEN RATHER THAN CAST AWAY. `flag()` types its
200
- * answer `string | undefined` because it serves OPTIONAL props, and `value` here is required,
201
- * so the branch cannot be taken; a cast would claim a proof the type system has not made.
202
- */
203
- updateForm: (props) => ({ value: flag(props.value) ?? '0' }),
204
- });
105
+ /**
106
+ * ⚠️ `value` IS THE ONLY THING COMPARED, AND THE LIST OF THINGS DELIBERATELY NOT COMPARED IS THE
107
+ * point of this resource. `flag` is props-derived (see `CephFlagAttributes`). `description`
108
+ * and `name` exist only on the bulk GET, which this file does not read, so neither can be
109
+ * compared by accident. Nothing PVE returns here is rewritten, re-ordered or re-typed by the
110
+ * cluster, because all PVE returns here is one boolean.
111
+ * ★ MEASURED: on C1, `GET /cluster/ceph/flags` answers value 0 for all eleven flags, so a
112
+ * declaration of `value: false` reads back false and plans `noop`.
113
+ */
114
+ const matches = (attributes: CephFlagAttributes, props: CephFlagProps) =>
115
+ attributes.value === props.value;
205
116
 
117
+ /**
118
+ * ★ MIGRATED OFF `pveHandlers`/`client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s
119
+ * typed `cluster.getClusterCephFlag`/`cluster.putClusterCephFlag` (ceph-flag-wire.ts).
120
+ * `pveHandlers` cannot run a distilled operation, so `diff`/`reconcile` are hand-written here,
121
+ * the same way ceph-pool.ts and backup-job.ts hand-write theirs for the same reason.
122
+ *
123
+ * ⛔ THIS FAMILY HAS NO CREATE KEY, AND THAT IS THE VENDOR'S DOING, NOT AN OMISSION HERE. PVE
124
+ * registers only `PUT /cluster/ceph/flags/{flag}`; `POST /cluster/ceph/flags` does not exist
125
+ * (constraints-forms.test.ts's own regression test), which is why the second ⛔ in the header
126
+ * above calls the create branch unreachable — there is no create form and no create endpoint to
127
+ * guard, only `CEPH_FLAG_UPDATE`.
128
+ */
206
129
  export const ProxmoxCephFlagProvider = () =>
207
130
  Provider.effect(
208
131
  ProxmoxCephFlag,
209
132
  Effect.succeed(
210
133
  ProxmoxCephFlag.Provider.of({
211
- /**
212
- * ⚠️ NOT THE ONE-LINE `Provider.of(handlers)` EVERY OTHER FILE HERE USES, AND THE TWO
213
- * REASONS ARE BOTH acl.ts's. PVE implements no DELETE on this path, and the factory's
214
- * read-back guard is dead whenever `attributes` cannot answer undefined. Unlike acl.ts
215
- * this SPREADS the factory's handlers instead of retyping them, so `list`, `read` and
216
- * `diff` cannot drift from the shared ones — only the two that genuinely differ are
217
- * written out below.
218
- */
219
- ...handlers,
134
+ /** ⛔ Empty, like every resource in this package — see resource.ts's own ⚠️ on why. */
135
+ list: () => Effect.succeed([]),
136
+ read: ({ olds }) => readClusterCephFlag(olds),
137
+ diff: Effect.fn(function* ({ news, output }) {
138
+ if (!isResolved(news)) return undefined;
139
+ yield* guardWrite(CEPH_FLAG_UPDATE, updateForm(news), false);
140
+ if (output === undefined) return undefined;
141
+ // ⛔ Never absent — see the second ⛔ above. A failed read other than a refused mint
142
+ // propagates instead of reaching this point. A refused mint must NOT fail `diff`:
143
+ // that aborts the whole plan (unreadable-read.ts). `read` still propagates.
144
+ const live = yield* readClusterCephFlagForDiff(news);
145
+ if (live === UNREADABLE) {
146
+ yield* unreadableWarning(
147
+ 'Proxmox.CephFlag',
148
+ news.flag,
149
+ news.target.mount,
150
+ mintTier(news.target, 'read'),
151
+ );
152
+ return { action: 'noop' } as const;
153
+ }
154
+ return { action: matches(live, news) ? 'noop' : 'update' } as const;
155
+ }),
220
156
  /** ⛔ Inert, deliberately. The third ⛔ in the header is the whole argument; read it. */
221
157
  delete: () => Effect.void,
222
158
  /**
223
- * ⚠️ THE READ-BACK GUARD, RESTORED FOR A FAMILY WHOSE "ABSENT" DOES NOT EXIST.
224
- * `ops.reconcile` refuses when the object is still missing after a write, and a flag is
225
- * never missing, so that check can never fire here — exactly the hole acl.ts patches
226
- * with `bound`. PVE answers 200 with `{"data":null}` on calls that did nothing, and this
227
- * PUT's documented return IS null, so the status code carries no evidence at all.
159
+ * ⚠️ THE READ-BACK GUARD, RESTORED FOR A FAMILY WHOSE "ABSENT" DOES NOT EXIST. A generic
160
+ * `reconcile` (resource.ts) refuses when the object is still missing after a write, and
161
+ * a flag is never missing, so that check could never fire here. PVE answers 200 with
162
+ * `{"data":null}` on calls that did nothing, and this PUT's documented return IS null,
163
+ * so the status code carries no evidence at all — only a read-back does.
228
164
  * ⚠️ THE PER-FLAG PUT IS SYNCHRONOUS (`$rados->mon_command` inline, MEASURED in Ceph.pm),
229
165
  * which is what makes this check fair rather than a race against a worker task. If it
230
166
  * ever fires spuriously the honest fix is to look at the mon, not to delete the guard.
167
+ * ⛔ `formToSend` IS WHAT MAKES ADOPTING AN ALREADY-MATCHING FLAG FREE. Alchemy's
168
+ * `adopted` action reaches `reconcile` exactly like `update` does (resource.ts's own ⛔
169
+ * on this), so without this guard adopting a flag that already reads correctly would
170
+ * still fire a PUT — pointless against ceph, and update-guard.ts's header has the
171
+ * incident where the equivalent gap on CephPool forked an unnecessary worker.
231
172
  */
232
173
  reconcile: Effect.fn(function* ({ news }) {
233
- const after = yield* handlers.reconcile({ news });
174
+ const live = yield* readClusterCephFlag(news);
175
+ const form = formToSend(matches, live, news, updateForm(news));
176
+ if (form !== undefined) {
177
+ yield* guardWrite(CEPH_FLAG_UPDATE, updateForm(news), false);
178
+ yield* writeClusterCephFlag(news);
179
+ }
180
+ const after = yield* readClusterCephFlag(news);
234
181
  if (after.value !== news.value) {
235
182
  return yield* Effect.die(
236
183
  new Error(
@@ -102,19 +102,16 @@ export const createOsd = Effect.fn(function* (news: CephOsdProps) {
102
102
  * ⚠️ `cleanup` DEFAULTS TO FALSE, so the logical volumes survive and the disk can be re-added
103
103
  * without a rebuild. Ceph still starts backfilling the missing copies the moment the OSD goes.
104
104
  *
105
- * ⚠️ `cleanup` RIDES A DELETE BODY, ON `client.ts` AND ON DISTILLED ALIKE — PRE-EXISTING, NOT
106
- * INTRODUCED HERE. `client.ts`'s own `buildRequest` (client.ts) puts `form` in the body for
107
- * every method, DELETE included, and CHECKED against distilled's generated
108
- * `DeleteNodeCephOsdRequest`: `cleanup` carries no `T.Query()` annotation either, so it also
109
- * defaults to a body field. ceph-fs-wire.ts's own ⛔ (measured from PVE's `AnyEvent.pm`) is that
110
- * PVE's server never reads a body on DELETE — so `cleanup=1` was ALREADY silently ignored by
111
- * the live cluster before this migration, on the unmigrated code too. Migrating carries the
112
- * SAME behaviour forward exactly, byte-for-byte, rather than introducing a new gap; unlike
113
- * `Proxmox.CephFs`'s delete, this is not a reason to keep the call on the hand client. It has
114
- * zero practical effect today regardless, since the whole DELETE 403s for this package's
115
- * credential either way (the root-only ⛔ at the top of ceph-osd.ts) — flagged separately as a
116
- * pre-existing, out-of-scope bug worth its own fix (move `cleanup` into the query string, the
117
- * way `destroyPath` in ceph-fs-wire.ts already does).
105
+ * ★ CORRECTED 2026-09-28: `cleanup` IS QUERY-BOUND ON DISTILLED, NOT BODY. This comment previously
106
+ * claimed `DeleteNodeCephOsdRequest.cleanup` carried no `T.Query()` annotation and so rode the
107
+ * DELETE body, silently ignored by PVE's server (which never reads a body on DELETE, per
108
+ * ceph-fs-wire.ts's measurement of `AnyEvent.pm`). That was true of the old `client.ts` hand
109
+ * client, but distilled-proxmox's generated schema
110
+ * (`packages/distilled-proxmox/src/services/nodes.ts`) declares
111
+ * `cleanup: S.optional(S.String.pipe(T.Query()))` — query-bound — as of SDK 0.3.0, the same fix
112
+ * that landed for the sibling `Proxmox.CephFs`'s `destroyFs`. Since this file already runs on
113
+ * distilled (`import * as nodes from '@distilled.cloud/proxmox/nodes'`), `cleanup: '1'` below
114
+ * reaches PVE correctly today. No code change needed here; this was a stale doc claim only.
118
115
  */
119
116
  export const destroyOsd = (olds: CephOsdProps) =>
120
117
  runPve(
@@ -3,11 +3,10 @@
3
3
  * 250-line cap once this class grew a real header (2026-09-24, the cries-wolf fix).
4
4
  */
5
5
  import * as Data from 'effect/Data';
6
- import type { PveRole } from './credentials.ts';
7
6
 
8
7
  /**
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
8
+ * OpenBao answered 403 to `GET /v1/<mount>/creds/<tier>`: this identity's AppRole has no grant
9
+ * for `tier` on `mount` — measured 2026-09-24 as "the agent lane cannot mint `provision`" (the
11
10
  * lane's policy grants `read` but not `provision`). A TYPED tag, not the plain `Error` every
12
11
  * other mint failure still is (404 no such role/mount, 503 sealed, a network failure), because
13
12
  * this ONE case means "refused, not evidence of anything about the object" — a family's
@@ -18,15 +17,30 @@ import type { PveRole } from './credentials.ts';
18
17
  * BROKEN, not this identity being narrowly scoped — folding those into "unreadable, report
19
18
  * noop" would hide a real outage behind a quiet, misleadingly reassuring plan. Only 403 is
20
19
  * "this identity, this role, denied" and safe to read as "cannot see, not evidence of absence".
20
+ *
21
+ * ⛔ NO `role` FIELD (removed, K-T1 second red-team pass). `mint.ts` knows the semantic `PveRole`
22
+ * it was asked for, but `lease-cache.ts`'s synthetic `mintForKey` reconstruction (after M3
23
+ * dropped `role` from `LeaseKey`) cannot supply the ORIGINAL caller's role on a cache-path
24
+ * mint — only the tier. A `role` field would then read as `'read'` on every pve cache miss
25
+ * regardless of which role was actually denied, which is exactly the stale/misleading-field
26
+ * trap M6 existed to close, one layer down. `tier` alone is what the message reads and what
27
+ * `readOrUnreadable`'s callers act on; nothing needs the semantic role name.
21
28
  */
22
29
  export class PveCredentialDenied extends Data.TaggedError('PveCredentialDenied')<{
23
30
  readonly mount: string;
24
- readonly role: PveRole;
31
+ /**
32
+ * The OpenBao mint TIER actually requested (`credentials.ts`'s `mintTier`). ⛔ THE MESSAGE
33
+ * READS THIS, NOT A SEMANTIC ROLE NAME: printing `'read'` for a denied `talos-provision`
34
+ * mint would spell it as `creds/read`, exactly the path that was never called — silently
35
+ * wrong at precisely the moment an operator is reading this string to decide whether a
36
+ * grant, a policy or a target config is wrong (K-T1's own PR discussion).
37
+ */
38
+ readonly tier: string;
25
39
  readonly detail: string;
26
40
  }> {
27
41
  override get message(): string {
28
42
  return (
29
- `OpenBao GET /v1/${this.mount}/creds/${this.role} -> 403: ${this.detail}. This identity's ` +
43
+ `OpenBao GET /v1/${this.mount}/creds/${this.tier} -> 403: ${this.detail}. This identity's ` +
30
44
  'AppRole has no grant for this role -- a refused read, not evidence the object is gone.'
31
45
  );
32
46
  }
@@ -12,7 +12,7 @@ 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, PveTarget } from './credentials.ts';
15
+ import { type BaoEnvironment, type PbsTarget, type PveTarget, mintTier } from './credentials.ts';
16
16
  import { mint } from './mint.ts';
17
17
 
18
18
  /**
@@ -165,4 +165,76 @@ describe('mint', () => {
165
165
  );
166
166
  rmSync(socket, { force: true });
167
167
  });
168
+
169
+ // ⛔ K-T1: the whole point of `target.roles` is that a real HTTP request goes to the
170
+ // OVERRIDDEN tier's path, not the bare role's — checked end to end here, not only through
171
+ // `mintTier`'s own unit tests below.
172
+ it('requests the overridden tier, not the bare role, when target.roles names one', async () => {
173
+ const scoped: PveTarget = { ...C1, roles: { read: 'talos-provision' } };
174
+ await withFake(CREDENTIAL, async (address, seen) => {
175
+ await Effect.runPromise(
176
+ mint(scoped, 'read', { BAO_ADDR: address, BAO_TOKEN: TOKEN }).pipe(
177
+ Effect.provide(FetchHttpClient.layer),
178
+ ),
179
+ );
180
+ assert.equal(seen[0]?.path, '/v1/proxmox-c1/creds/talos-provision');
181
+ });
182
+ });
183
+
184
+ // ⛔ A role `target.roles` does not mention falls back to itself — an override on `read` alone
185
+ // must never touch `provision` on the same target.
186
+ it('leaves an unmentioned role alone on a partially overridden target', async () => {
187
+ const scoped: PveTarget = { ...C1, roles: { read: 'talos-provision' } };
188
+ await withFake(CREDENTIAL, async (address, seen) => {
189
+ await Effect.runPromise(
190
+ mint(scoped, 'provision', { BAO_ADDR: address, BAO_TOKEN: TOKEN }).pipe(
191
+ Effect.provide(FetchHttpClient.layer),
192
+ ),
193
+ );
194
+ assert.equal(seen[0]?.path, '/v1/proxmox-c1/creds/provision');
195
+ });
196
+ });
197
+ });
198
+
199
+ describe('mintTier', () => {
200
+ it('is the bare role when the target carries no override', () => {
201
+ assert.equal(mintTier(C1, 'read'), 'read');
202
+ assert.equal(mintTier(C1, 'provision'), 'provision');
203
+ });
204
+
205
+ it('reads a PveTarget.roles override when one names the role', () => {
206
+ const scoped: PveTarget = { ...C1, roles: { provision: 'talos-provision' } };
207
+ assert.equal(mintTier(scoped, 'provision'), 'talos-provision');
208
+ assert.equal(mintTier(scoped, 'read'), 'read', 'unmentioned role: unchanged');
209
+ });
210
+
211
+ // ⛔ PbsTarget carries no `roles` field at all (credentials.ts's own ⛔) — this pins that a PBS
212
+ // target can never override, structurally, not just "nobody has set one yet".
213
+ it('never overrides a PbsTarget: the field does not exist on that type', () => {
214
+ const pbs: PbsTarget = {
215
+ api: 'https://pbs.example:8007/api2/json',
216
+ mount: 'pbs-c1',
217
+ scheme: 'pbs',
218
+ };
219
+ assert.equal(mintTier(pbs, 'read'), 'read');
220
+ assert.equal(mintTier(pbs, 'provision'), 'provision');
221
+ });
222
+
223
+ // K-T1/M5 (second red-team pass): an override that resolves to a DIFFERENT OpenBao path than
224
+ // the one it names must throw, not silently mint a wider or nonexistent tier. Each of these
225
+ // reaches a different path than `<mount>/creds/<tier>` while containing no literal `/`.
226
+ const invalidTiers: Array<[tier: string, reason: string]> = [
227
+ ['', 'an empty override falls back to the bare role rather than minting <mount>/creds/'],
228
+ ['..', 'a parent-path segment escapes the creds/ prefix entirely'],
229
+ ['.', 'a same-directory segment is not a real tier name'],
230
+ ['?x=y', 'a querystring is appended instead of naming a tier'],
231
+ ['#frag', 'a fragment is appended instead of naming a tier'],
232
+ ['a/b', 'a literal slash inserts a second path segment'],
233
+ ];
234
+ for (const [bad, reason] of invalidTiers) {
235
+ it(`rejects ${JSON.stringify(bad)} as a tier name: ${reason}`, () => {
236
+ const scoped: PveTarget = { ...C1, roles: { provision: bad } };
237
+ assert.throws(() => mintTier(scoped, 'provision'), /not a valid OpenBao tier name/);
238
+ });
239
+ }
168
240
  });
@@ -86,9 +86,42 @@ export type PveTarget = {
86
86
  * and a wrong guess fails closed with a 401 rather than doing something odd.
87
87
  */
88
88
  readonly scheme: 'pve';
89
+ /**
90
+ * Overrides which OpenBao mint TIER NAME (`<mount>/creds/<tier>`) a semantic role resolves to.
91
+ * Omitted or missing a role: unchanged, `role` is also the tier name, as every consumer before
92
+ * this field got. Present: `mintTier` below reads it first.
93
+ *
94
+ * ★ WHY A TARGET-LEVEL OVERRIDE, NOT A THIRD `PveRole`. A consumer whose OpenBao mount vends a
95
+ * single least-privilege tier for BOTH read and write (one tier that can only ever reach the
96
+ * scope that consumer needs, rather than the estate's general `read`/`provision` split) sets
97
+ * `roles: { read: 'my-tier', provision: 'my-tier' }` and every read/diff/reconcile/delete this
98
+ * package already issues resolves to that tier, with no new call site anywhere in the package.
99
+ * Widening `PveRole` itself to a per-consumer string would instead touch every literal
100
+ * `'read'`/`'provision'` call site across the package (`client.ts`, `lxc-read.ts`,
101
+ * `lxc-task.ts`, `network-apply-read.ts`, `network-apply-run.ts`, `qemu-read.ts`,
102
+ * `qemu-task.ts`, `storage-download-task.ts` — the eight this field's `mintTier` indirection
103
+ * above already covers) for something only the TARGET, never the CALL, needs to know.
104
+ *
105
+ * ⛔ SET BOTH ROLES OR NEITHER (I2, red team on K-T1). An unmentioned role is NOT scoped down —
106
+ * it still mints the estate-wide tier this file's own header describes (`hf-provision@pve`,
107
+ * 27 privileges including `Permissions.Modify`/`Sys.Modify` for `provision`). Setting only
108
+ * `roles: { read: 'my-tier' }` leaves every reconcile/delete on this target minting that wide
109
+ * credential while the plan output looks fully scoped down — nothing here warns. See
110
+ * `docs/credentials.md`'s override paragraph for the same warning at the consumer-facing level.
111
+ */
112
+ readonly roles?: {
113
+ readonly read?: string;
114
+ readonly provision?: string;
115
+ };
89
116
  };
90
117
 
91
- /** The same client against a Proxmox Backup Server. See the ⛔ on `scheme` above. */
118
+ /**
119
+ * The same client against a Proxmox Backup Server. See the ⛔ on `scheme` above.
120
+ * ⛔ NO `roles` FIELD. The estate has no PBS consumer with a scoped tier yet, and a PBS mount
121
+ * vending only `read`/`provision` is the same shape `mint.ts` always assumed — `mintTier` below
122
+ * never overrides a PBS target for exactly that reason. Add the field here, the same way, when
123
+ * a PBS consumer needs it.
124
+ */
92
125
  export type PbsTarget = {
93
126
  readonly mount: string;
94
127
  readonly api: string;
@@ -98,6 +131,44 @@ export type PbsTarget = {
98
131
  /** Either product. What `pve()` and `pveOperations` accept; what a RESOURCE accepts is narrower. */
99
132
  export type ApiTarget = PbsTarget | PveTarget;
100
133
 
134
+ /**
135
+ * The OpenBao mint tier a role resolves to for `target` — `target.roles`'s override (PVE only)
136
+ * or `role` itself, unchanged. THE single place `<mount>/creds/<tier>` gets its tier segment from
137
+ * a `PveRole`: `mint.ts`'s path and `lease-cache.ts`'s cache key both call this rather than either
138
+ * reading `role` or `target.roles` directly, so there is exactly one definition of "the tier" for
139
+ * both to agree on — `lease-cache.ts`'s own header says why the cache needed this too, not just
140
+ * the mint call.
141
+ *
142
+ * ⛔ AN OVERRIDE THAT IS NOT A PLAIN TIER NAME THROWS RATHER THAN BUILDING A PATH (M5, red team on
143
+ * K-T1; widened after a second red-team pass found the first fix incomplete). `role` itself is
144
+ * a compile-time literal and never needs this check; only `target.roles`, a plain string a
145
+ * caller can set to anything, does. This is an ALLOWLIST, not a denylist of `/` alone — the
146
+ * value is concatenated straight into `${mount}/creds/${tier}` and then into a URL (`mint.ts`),
147
+ * so `''`, `'..'`, `'?x'` and `'#x'` are each their own way to land on a DIFFERENT OpenBao path
148
+ * (a dropped segment, a parent path, a querystring, a fragment) without containing `/` at all.
149
+ * Every one of those produces a 404 `mint.ts`'s own `refuse` reports as a mint failure, and
150
+ * every `pveOperations` family folds a mint failure into "absent" (`unreadable-read.ts`'s ⛔),
151
+ * so the plan would say CREATE for an object that is plainly there instead of naming the bad
152
+ * config — the exact hazard the first version of this check believed it had closed. `role` is
153
+ * source-controlled and this fires at the first `mintTier` call, before any network request, so
154
+ * the fix cannot itself widen what an operator's config can reach.
155
+ */
156
+ const VALID_TIER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
157
+
158
+ export const mintTier = (target: ApiTarget, role: PveRole): string => {
159
+ if (target.scheme !== 'pve') return role;
160
+ const tier = target.roles?.[role];
161
+ if (tier === undefined) return role;
162
+ if (!VALID_TIER.test(tier)) {
163
+ throw new Error(
164
+ `PveTarget.roles.${role} = ${JSON.stringify(tier)} is not a valid OpenBao tier name -- ` +
165
+ 'it would be interpolated straight into "<mount>/creds/<tier>" and must be a plain name ' +
166
+ '(letters, digits, ".", "_", "-", not starting with one of those and not "..").',
167
+ );
168
+ }
169
+ return tier;
170
+ };
171
+
101
172
  /**
102
173
  * What a mint returns.
103
174
  *
@@ -85,6 +85,7 @@ import {
85
85
  } from './group-wire.ts';
86
86
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
87
87
  import { runPve } from './distilled-pve.ts';
88
+ import { mintTier } from './credentials.ts';
88
89
  import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
89
90
 
90
91
  export interface GroupProps extends WithTarget {
@@ -162,7 +163,12 @@ export const ProxmoxGroupProvider = () =>
162
163
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
163
164
  // `update` on a group that was plainly there — see unreadable-read.ts.
164
165
  if (live === UNREADABLE) {
165
- yield* unreadableWarning('Proxmox.Group', news.groupid);
166
+ yield* unreadableWarning(
167
+ 'Proxmox.Group',
168
+ news.groupid,
169
+ news.target.mount,
170
+ mintTier(news.target, 'read'),
171
+ );
166
172
  return { action: 'noop' } as const;
167
173
  }
168
174
  if (live === undefined) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ⚠️ EXERCISES `mintForKey` ITSELF, NOT A HAND-REPRODUCTION OF ITS CHOICES (second red-team pass
3
+ * on K-T1). `lease-cache.test.ts`'s `targetForKey` tests call `mintTier(targetForKey(key),
4
+ * 'read')` — passing `'read'` as a LITERAL that happens to match `mintForKey`'s own hardcoded
5
+ * pve role, so those tests would keep passing even if `mintForKey` minted with a different role
6
+ * than it reconstructs the target for. A fake OpenBao server pins the ACTUAL request path
7
+ * `mintForKey` sends.
8
+ *
9
+ * Split out of lease-cache.test.ts (2026-09-27) to keep that file under the 250-line cap once this
10
+ * describe block needed its own fake-server helper and imports — same reasoning as
11
+ * tests/apply-main-ruleset-update.test.ts.
12
+ */
13
+ import assert from 'node:assert/strict';
14
+ import { describe, it } from 'node:test';
15
+ import * as Effect from 'effect/Effect';
16
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
17
+ import { type BaoEnvironment } from './credentials.ts';
18
+ import { type LeaseKey, mintForKey } from './lease-cache.ts';
19
+
20
+ /** ⚠️ Same minimal Bun typing as credentials.test.ts — this package's tsconfig has no Bun types. */
21
+ type ServeOptions = { fetch(request: Request): Promise<Response>; hostname: string; port: number };
22
+ const { Bun } = globalThis as unknown as {
23
+ Bun: { serve(options: ServeOptions): { url: URL; stop(closeActive: boolean): void } };
24
+ };
25
+
26
+ describe("K-T1/M3: mintForKey requests the key's own tier, not a re-derived role", () => {
27
+ const withFakeBao = async (body: (address: string, seen: string[]) => Promise<void>) => {
28
+ const seen: string[] = [];
29
+ const server = Bun.serve({
30
+ fetch: async (request: Request) => {
31
+ seen.push(new URL(request.url).pathname);
32
+ return Response.json({ data: { secret: 'x', token_id: 'hf-read@pve!hf-read-1' } });
33
+ },
34
+ hostname: '127.0.0.1',
35
+ port: 0,
36
+ });
37
+ try {
38
+ await body(server.url.origin, seen);
39
+ } finally {
40
+ server.stop(true);
41
+ }
42
+ };
43
+ const env = (address: string): BaoEnvironment => ({ BAO_ADDR: address, BAO_TOKEN: 't' });
44
+ const run = (key: LeaseKey, address: string) =>
45
+ Effect.runPromise(mintForKey(key, env(address)).pipe(Effect.provide(FetchHttpClient.layer)));
46
+
47
+ it('a plain pve key mints /creds/<tier> with the bare role tier', async () => {
48
+ await withFakeBao(async (address, seen) => {
49
+ const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'read' };
50
+ await run(key, address);
51
+ assert.equal(seen[0], '/v1/proxmox-tb4/creds/read');
52
+ });
53
+ });
54
+
55
+ it('an overridden pve key mints the OVERRIDDEN tier, not a bare role', async () => {
56
+ await withFakeBao(async (address, seen) => {
57
+ const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'talos-provision' };
58
+ await run(key, address);
59
+ assert.equal(seen[0], '/v1/proxmox-tb4/creds/talos-provision');
60
+ });
61
+ });
62
+
63
+ it('a pbs key mints its own tier directly', async () => {
64
+ await withFakeBao(async (address, seen) => {
65
+ const key: LeaseKey = { mount: 'pbs-c1', scheme: 'pbs', tier: 'provision' };
66
+ await run(key, address);
67
+ assert.equal(seen[0], '/v1/pbs-c1/creds/provision');
68
+ });
69
+ });
70
+ });