@homeflare/alchemy 0.41.0 → 0.42.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 (38) hide show
  1. package/dist/proxmox/acl.d.ts.map +1 -1
  2. package/dist/proxmox/api-token.d.ts.map +1 -1
  3. package/dist/proxmox/credential-errors.d.ts +18 -4
  4. package/dist/proxmox/credential-errors.d.ts.map +1 -1
  5. package/dist/proxmox/credentials.d.ts +35 -1
  6. package/dist/proxmox/credentials.d.ts.map +1 -1
  7. package/dist/proxmox/group.d.ts.map +1 -1
  8. package/dist/proxmox/index.js +34 -18
  9. package/dist/proxmox/index.js.map +16 -15
  10. package/dist/proxmox/lease-cache.d.ts +67 -5
  11. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  12. package/dist/proxmox/mint.d.ts +4 -2
  13. package/dist/proxmox/mint.d.ts.map +1 -1
  14. package/dist/proxmox/node-network.d.ts.map +1 -1
  15. package/dist/proxmox/role.d.ts.map +1 -1
  16. package/dist/proxmox/storage.d.ts.map +1 -1
  17. package/dist/proxmox/unreadable-read.d.ts +8 -1
  18. package/dist/proxmox/unreadable-read.d.ts.map +1 -1
  19. package/dist/proxmox/user.d.ts.map +1 -1
  20. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  21. package/docs/credentials.md +14 -1
  22. package/package.json +1 -1
  23. package/src/proxmox/acl.ts +7 -1
  24. package/src/proxmox/api-token.ts +7 -1
  25. package/src/proxmox/credential-errors.ts +19 -5
  26. package/src/proxmox/credentials.test.ts +73 -1
  27. package/src/proxmox/credentials.ts +72 -1
  28. package/src/proxmox/group.ts +7 -1
  29. package/src/proxmox/lease-cache-mint.test.ts +70 -0
  30. package/src/proxmox/lease-cache.test.ts +83 -7
  31. package/src/proxmox/lease-cache.ts +74 -10
  32. package/src/proxmox/mint.ts +12 -5
  33. package/src/proxmox/node-network.ts +7 -1
  34. package/src/proxmox/role.ts +7 -1
  35. package/src/proxmox/storage.ts +7 -1
  36. package/src/proxmox/unreadable-read.ts +16 -4
  37. package/src/proxmox/user.ts +7 -1
  38. package/src/proxmox/zfs-pool.ts +7 -1
@@ -47,23 +47,49 @@ import * as Cache from 'effect/Cache';
47
47
  import * as Duration from 'effect/Duration';
48
48
  import * as Effect from 'effect/Effect';
49
49
  import * as Exit from 'effect/Exit';
50
- import { type ApiTarget, type PveCredential, type PveRole } from './credentials.ts';
50
+ import { type ApiTarget, type BaoEnvironment, type PveCredential, type PveRole } from './credentials.ts';
51
51
  /**
52
52
  * What a credential is cached under.
53
53
  *
54
- * ⛔ KEYED ON THE OPENBAO MOUNT AND ROLE, NOT ON A MEMBER HOSTNAME. A PVE API token is
55
- * cluster-wide — `user.cfg` is replicated — so node-b, node-c and node-d share one credential. Keying on
56
- * `api` would mint three identical tokens when failover rotates members.
54
+ * ⛔ KEYED ON THE OPENBAO MOUNT AND TIER, NOT ON A MEMBER HOSTNAME OR ON `role` (M3, red team on
55
+ * K-T1). A PVE API token is cluster-wide — `user.cfg` is replicated — so node-b, node-c and
56
+ * node-d share one credential. Keying on `api` would mint three identical tokens when failover
57
+ * rotates members. `role` USED to be part of this key too, but the minted credential depends
58
+ * only on `(mount, scheme, tier)` — `role` only ever fed `mintTier` to re-derive the tier the
59
+ * key already carries — so `roles: { read: 'x', provision: 'x' }` (one tier for both roles, the
60
+ * shape `docs/credentials.md` itself recommends) used to open TWO cache entries and mint TWO
61
+ * byte-for-byte interchangeable tokens for what is provably one credential. Dropping `role`
62
+ * collapses that back to one entry without changing the default case, where `tier === role`
63
+ * already.
57
64
  * ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-c1`, `proxmox-c2` and `pbs-c1` are
58
65
  * distinct OpenBao mounts with distinct policies; two estates never share a mount name.
59
66
  * ★ A PLAIN OBJECT IS A SAFE KEY BECAUSE EFFECT COMPARES KEYS STRUCTURALLY — two separately built
60
67
  * literals with the same fields are one entry, which the probe above measured.
68
+ *
69
+ * ⛔ `tier` IS THE KEY, NOT `role` (K-T1). `targetForKey` below has to RECONSTRUCT a target from
70
+ * the key alone (it does not keep the caller's original `ApiTarget` around), and a
71
+ * `PveTarget.roles` override lives on THAT original target, not on `role` — so without `tier`
72
+ * in the key, every `leased()` call for an overridden role would resolve its override once at
73
+ * the first `keyOf`, then reuse whatever `targetForKey` happens to reconstruct on a cache miss,
74
+ * and TWO DIFFERENT overrides sharing one `{mount, scheme}` (e.g. one consumer's default `read`
75
+ * tier and another's `roles: {read: 'x'}` override, same mount) would collide into ONE cache
76
+ * entry and hand each other's credential to the wrong caller. Resolving the tier at `keyOf` —
77
+ * where the real target is still in hand — and keying on it closes that: two different tiers
78
+ * are two different entries, always.
61
79
  */
62
80
  export type LeaseKey = {
63
81
  readonly mount: string;
64
- readonly role: PveRole;
65
82
  readonly scheme: ApiTarget['scheme'];
83
+ readonly tier: string;
66
84
  };
85
+ /**
86
+ * ⚠️ EXPORTED FOR THE TEST (I1, red team on K-T1). This is the one line the whole PR exists
87
+ * for — `tier: mintTier(target, role)` is what makes an overridden `PveTarget.roles` reach the
88
+ * cached path at all. Nothing else in this file's test suite calls `keyOf` or `leased`
89
+ * directly, so without this export a regression here (e.g. reverting to `tier: role`) passed
90
+ * the whole suite silently.
91
+ */
92
+ export declare const keyOf: (target: ApiTarget, role: PveRole) => LeaseKey;
67
93
  /**
68
94
  * How long a freshly minted credential may be handed out again.
69
95
  *
@@ -87,6 +113,42 @@ export declare const timeToLive: (exit: Exit.Exit<PveCredential, unknown>) => Du
87
113
  * ⚠️ EXPORTED FOR THE TEST, which hands it a counting mint instead of calling OpenBao.
88
114
  */
89
115
  export declare const makeLeases: <E, R>(mintFor: (key: LeaseKey) => Effect.Effect<PveCredential, E, R>) => Effect.Effect<Cache.Cache<LeaseKey, PveCredential, E, R>, never, never>;
116
+ /**
117
+ * ⛔ CARRIES `key.tier` BACK IN AS A `roles` OVERRIDE (K-T1), SO `mint`'S OWN `mintTier` CALL
118
+ * REPRODUCES THE SAME TIER `keyOf` ALREADY RESOLVED. This target is synthetic — it is never the
119
+ * caller's real `PveTarget` (`members`/`api` are throwaway placeholders `mint` never reads) —
120
+ * and, since M3 dropped `role` from `LeaseKey`, it no longer even knows which semantic role
121
+ * produced `key.tier`. It doesn't need to: `roles: { read: key.tier }` plus always minting with
122
+ * the fixed role `'read'` (below) makes `mintTier(targetForKey(key), 'read') === key.tier`
123
+ * unconditionally, because `mintTier` only ever consults the ONE role it's called with. Which
124
+ * role the ORIGINAL caller asked for is irrelevant here — `keyOf` already folded that into
125
+ * `key.tier` before this function ever sees the key.
126
+ * ⚠️ THE PBS BRANCH MINTS WITH `key.tier` ITSELF AS THE ROLE, NOT `'read'`. A `PbsTarget` has no
127
+ * `roles` override (see `credentials.ts`'s ⛔ on `PbsTarget`), so `mintTier` for `scheme:'pbs'`
128
+ * always returns its `role` argument unchanged — meaning `key.tier` for a PBS key IS the literal
129
+ * role name (`'read'`/`'provision'`) `keyOf` recorded. Passing anything else here would ask
130
+ * OpenBao for the wrong tier.
131
+ * ⚠️ EXPORTED FOR THE TEST, which checks that round trip directly rather than only through the
132
+ * cache's externally visible behaviour.
133
+ */
134
+ export declare const targetForKey: (key: LeaseKey) => ApiTarget;
135
+ /**
136
+ * ⚠️ EXPORTED FOR THE TEST (second red-team pass on K-T1). `targetForKey`'s own tests call
137
+ * `mintTier(targetForKey(key), 'read')` to check the round trip, which hand-reproduces this
138
+ * function's fixed choice of `'read'` as the pve mint role rather than exercising it — change
139
+ * that choice here and those tests keep passing while a live cache miss mints the wrong tier.
140
+ * Testing this function directly (against a fake OpenBao) pins the choice itself.
141
+ *
142
+ * ⚠️ `env` DEFAULTS TO `process.env`, SAME AS `mint` ITSELF (credentials.ts) — the cache's real
143
+ * caller below (`Effect.runSync(makeLeases(mintForKey))`) always calls this with one argument,
144
+ * so the default is what production runs on. The parameter exists only so the test can pass a
145
+ * fake OpenBao's address without touching the real environment.
146
+ */
147
+ export declare const mintForKey: (key: LeaseKey, env?: BaoEnvironment) => Effect.Effect<{
148
+ leaseSeconds: number;
149
+ secret: string;
150
+ tokenId: string;
151
+ }, Error | import("./credential-errors.ts").PveCredentialDenied, import("effect/unstable/http/HttpClient").HttpClient>;
90
152
  /**
91
153
  * A credential for `role` on `target`: a still-valid lease when one is kept, one shared mint when
92
154
  * not.
@@ -1 +1 @@
1
- {"version":3,"file":"lease-cache.d.ts","sourceRoot":"","sources":["../../src/proxmox/lease-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAgBpF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAC;CACtC,CAAC;AAQF;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,SAAU,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC,KAAG,QAAQ,CAAC,QAG3D,CAAC;AAEpB;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC,EAAE,CAAC,CAAC,4EACX,CAAC;AASrF;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,WAAY,SAAS,QAAQ,OAAO,qJAA2C,CAAC"}
1
+ {"version":3,"file":"lease-cache.d.ts","sourceRoot":"","sources":["../../src/proxmox/lease-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,EACL,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,OAAO,EAEb,MAAM,kBAAkB,CAAC;AAgB1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAC;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,WAAY,SAAS,QAAQ,OAAO,KAAG,QAIvD,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,SAAU,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC,KAAG,QAAQ,CAAC,QAG3D,CAAC;AAEpB;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC,EAAE,CAAC,CAAC,4EACX,CAAC;AAErF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY,QAAS,QAAQ,KAAG,SAGoC,CAAC;AAElF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,QAAS,QAAQ,QAAO,cAAc;;;;sHACwB,CAAC;AAItF;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,WAAY,SAAS,QAAQ,OAAO,qJAA2C,CAAC"}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<role>`, alive for five
2
+ * Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<tier>`, alive for five
3
3
  * minutes. Split out of `credentials.ts` (2026-09-24) to keep that file's TYPES under the
4
4
  * 250-line cap once `PveCredentialDenied`'s catchers needed a real header there; the reasoning
5
5
  * for WHY the estate mints rather than stores a token is still credentials.ts's own header.
@@ -12,7 +12,9 @@ import * as HttpClient from 'effect/unstable/http/HttpClient';
12
12
  import { PveCredentialDenied } from './credential-errors.ts';
13
13
  import type { ApiTarget, BaoEnvironment, PveCredential, PveRole } from './credentials.ts';
14
14
  /**
15
- * Mint one credential for `role`: `GET /v1/<mount>/creds/<role>` on OpenBao's HTTP API.
15
+ * Mint one credential for `role`: `GET /v1/<mount>/creds/<tier>` on OpenBao's HTTP API, where
16
+ * `tier` is `mintTier(target, role)` (credentials.ts) — `role` itself unless `target.roles`
17
+ * overrides it (K-T1).
16
18
  *
17
19
  * 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
18
20
  * client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
@@ -1 +1 @@
1
- {"version":3,"file":"mint.d.ts","sourceRoot":"","sources":["../../src/proxmox/mint.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAIxC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAE9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAM1F;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,IAAI,WAAY,SAAS,QAAQ,OAAO,QAAO,cAAc;;;;sDAoGtE,CAAC;AAEL;;;;;GAKG;AACH,eAAO,MAAM,aAAa,eAAgB,aAAa,UAAU,SAAS,KAAG,MAGf,CAAC"}
1
+ {"version":3,"file":"mint.d.ts","sourceRoot":"","sources":["../../src/proxmox/mint.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAIxC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAE9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAM1F;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,IAAI,WAAY,SAAS,QAAQ,OAAO,QAAO,cAAc;;;;sDAwGtE,CAAC;AAEL;;;;;GAKG;AACH,eAAO,MAAM,aAAa,eAAgB,aAAa,UAAU,SAAS,KAAG,MAGf,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"node-network.d.ts","sourceRoot":"","sources":["../../src/proxmox/node-network.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,EAGL,KAAK,gBAAgB,EAGtB,MAAM,wBAAwB,CAAC;AAOhC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAG1D,YAAY,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAIrF,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,MAAM,WAAW,kBAAmB,SAAQ,QAAQ,CAClD,qBAAqB,EACrB,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,gGAAgG;AAChG,eAAO,MAAM,kBAAkB,qDAE7B,CAAC;AAiCH,eAAO,MAAM,0BAA0B,wIAuFpC,CAAC"}
1
+ {"version":3,"file":"node-network.d.ts","sourceRoot":"","sources":["../../src/proxmox/node-network.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,EAGL,KAAK,gBAAgB,EAGtB,MAAM,wBAAwB,CAAC;AAOhC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAGpE,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAG1D,YAAY,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAIrF,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,MAAM,WAAW,kBAAmB,SAAQ,QAAQ,CAClD,qBAAqB,EACrB,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,gGAAgG;AAChG,eAAO,MAAM,kBAAkB,qDAE7B,CAAC;AAiCH,eAAO,MAAM,0BAA0B,wIA4FpC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/proxmox/role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAa7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAStE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAwBjE,eAAO,MAAM,mBAAmB,iIA+E7B,CAAC"}
1
+ {"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/proxmox/role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAa7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAUtE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAwBjE,eAAO,MAAM,mBAAmB,iIAoF7B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/proxmox/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAYtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,gGAAgG;AAChG,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC;AAElD,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,wFAAwF;IACxF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,QAAQ,CAAC,EAAE,KAAK,CAAC;CAClB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,qGAAqG;AACrG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,oIAuGhC,CAAC"}
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/proxmox/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAatE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,gGAAgG;AAChG,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC;AAElD,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,wFAAwF;IACxF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,QAAQ,CAAC,EAAE,KAAK,CAAC;CAClB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,qGAAqG;AACrG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,oIA4GhC,CAAC"}
@@ -45,6 +45,13 @@ export declare const readOrUnreadable: <A, E, R>(effect: Effect.Effect<A, E | Pv
45
45
  * in `bun run plan`'s own output (Effect's default logger), even though the Diff type has
46
46
  * nowhere to carry the distinction through to the printed action. See the header for why a log
47
47
  * line, not a failed diff or a fourth Diff action, is what "the plan reports" means here.
48
+ *
49
+ * ⛔ M6 (Opus red team, K-T1): NAMES `mount` AND `tier`, NOT JUST THE RESOURCE. Before this, the
50
+ * line read "no grant for this credential's role" with nothing to act on — an operator debugging
51
+ * a scoped-down `PveTarget.roles` override (the whole point of K-T1) had to go re-derive which
52
+ * mount and tier the read actually asked OpenBao for before they could even open that mount's
53
+ * policy. `PveCredentialDenied`'s own message already made this exact call — reading `tier`, not
54
+ * `role`, per that class's own ⛔ — so this warning takes the same two fields for the same reason.
48
55
  */
49
- export declare const unreadableWarning: (type: string, identity: string) => Effect.Effect<void>;
56
+ export declare const unreadableWarning: (type: string, identity: string, mount: string, tier: string) => Effect.Effect<void>;
50
57
  //# sourceMappingURL=unreadable-read.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"unreadable-read.d.ts","sourceRoot":"","sources":["../../src/proxmox/unreadable-read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAElE,sFAAsF;AACtF,eAAO,MAAM,UAAU,eAA2B,CAAC;AACnD,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAC;AAE3C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,UAC9B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,GAAG,mBAAmB,EAAE,CAAC,CAAC,KACnD,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,UAAU,EAAE,CAAC,EAAE,CAAC,CACkD,CAAC;AAExF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,SAAU,MAAM,YAAY,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAKlF,CAAC"}
1
+ {"version":3,"file":"unreadable-read.d.ts","sourceRoot":"","sources":["../../src/proxmox/unreadable-read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAElE,sFAAsF;AACtF,eAAO,MAAM,UAAU,eAA2B,CAAC;AACnD,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAC;AAE3C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,UAC9B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,GAAG,mBAAmB,EAAE,CAAC,CAAC,KACnD,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,UAAU,EAAE,CAAC,EAAE,CAAC,CACkD,CAAC;AAExF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB,SACtB,MAAM,YACF,MAAM,SACT,MAAM,QACP,MAAM,KACX,MAAM,CAAC,MAAM,CAAC,IAAI,CAKlB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AActE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mGAAmG;IACnG,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAEjE,eAAO,MAAM,mBAAmB,iIAgG7B,CAAC"}
1
+ {"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAetE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mGAAmG;IACnG,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAEjE,eAAO,MAAM,mBAAmB,iIAqG7B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"zfs-pool.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAGtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvE,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,mGAAmG;AACnG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,oIAyEhC,CAAC"}
1
+ {"version":3,"file":"zfs-pool.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAItE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvE,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,mGAAmG;AACnG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,oIA8EhC,CAAC"}
@@ -12,7 +12,20 @@ a denied grant rather than a missing file, and `Bearer ` + nothing 401s in a way
12
12
  like a bad credential.
13
13
 
14
14
  The Proxmox subpath mints every call's token from an OpenBao mount instead; see
15
- [proxmox.md](./proxmox.md).
15
+ [proxmox.md](./proxmox.md). Every call still asks for one of two semantic roles, `read` or
16
+ `provision` (`PveRole`, `credentials.ts`) — a `PveTarget` can optionally override which OpenBao
17
+ mint TIER NAME each one actually resolves to (`roles: { read?, provision? }`, K-T1, 2026-09-27):
18
+ one consumer with its own least-privilege tier for both roles on the SAME mount sets
19
+ `roles: { read: 'my-tier', provision: 'my-tier' }` and every call this package already makes
20
+ mints that tier instead, with no new call site anywhere in the package. `credentials.ts`'s
21
+ `mintTier` is the one place that resolution happens; `mint.ts` and `lease-cache.ts` both call it
22
+ rather than reading `role`/`target.roles` directly — see `mintTier`'s own header for why the
23
+ lease cache specifically needed it too, not just the mint call.
24
+
25
+ ⛔ **Override both roles or neither.** An unmentioned role is not scoped down — it still mints
26
+ the estate-wide `read`/`provision` tier. Setting only `roles: { read: 'my-tier' }` leaves every
27
+ reconcile/delete on that target minting the full-privilege `provision` credential while the plan
28
+ output looks entirely scoped down.
16
29
 
17
30
  The Forgejo subpath (2026-09-23, moved onto `@distilled.cloud/forgejo`) resolves credentials
18
31
  through the package's own `CredentialsFromEnv` layer, which reads `FORGEJO_URL` / `FORGEJO_TOKEN`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -50,6 +50,7 @@ import * as Effect from 'effect/Effect';
50
50
  import { attributesOf, bind, guardWrite, identity, matches, tuple } from './acl-wire.ts';
51
51
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
52
52
  import { runPve } from './distilled-pve.ts';
53
+ import { mintTier } from './credentials.ts';
53
54
  import {
54
55
  UNREADABLE,
55
56
  type Unreadable,
@@ -148,7 +149,12 @@ export const ProxmoxAclProvider = () =>
148
149
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and
149
150
  // force `update` on a grant that was plainly there — see unreadable-read.ts.
150
151
  if (live === UNREADABLE) {
151
- yield* unreadableWarning('Proxmox.Acl', identity(news));
152
+ yield* unreadableWarning(
153
+ 'Proxmox.Acl',
154
+ identity(news),
155
+ news.target.mount,
156
+ mintTier(news.target, 'provision'),
157
+ );
152
158
  return { action: 'noop' } as const;
153
159
  }
154
160
  return live !== undefined && matches(live, news)
@@ -52,6 +52,7 @@ import * as Provider from 'alchemy/Provider';
52
52
  import * as access from '@distilled.cloud/proxmox/access';
53
53
  import * as Effect from 'effect/Effect';
54
54
  import { API_TOKEN_UPDATE, attributesOf, matches, shape } from './api-token-form.ts';
55
+ import { mintTier } from './credentials.ts';
55
56
  import { guardWrite } from './distilled-guard.ts';
56
57
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
57
58
  import { runPve } from './distilled-pve.ts';
@@ -190,7 +191,12 @@ export const ProxmoxApiTokenProvider = () =>
190
191
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
191
192
  // `update` on a token that was plainly there — see unreadable-read.ts.
192
193
  if (live === UNREADABLE) {
193
- yield* unreadableWarning('Proxmox.ApiToken', `${news.userid}!${news.tokenid}`);
194
+ yield* unreadableWarning(
195
+ 'Proxmox.ApiToken',
196
+ `${news.userid}!${news.tokenid}`,
197
+ news.target.mount,
198
+ mintTier(news.target, 'provision'),
199
+ );
194
200
  return { action: 'noop' } as const;
195
201
  }
196
202
  // ⚠️ NO GUARDED-CREATE BRANCH HERE, UNLIKE EVERY OTHER FAMILY IN THIS SUB-AREA. There
@@ -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
+ });