@homeflare/alchemy 0.37.5 → 0.37.6

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 (49) hide show
  1. package/dist/openbao/auth-role-form.d.ts +0 -6
  2. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  3. package/dist/openbao/auth-role-identity.d.ts +11 -0
  4. package/dist/openbao/auth-role-identity.d.ts.map +1 -0
  5. package/dist/openbao/auth-role-wire.d.ts +23 -0
  6. package/dist/openbao/auth-role-wire.d.ts.map +1 -0
  7. package/dist/openbao/auth-role.d.ts.map +1 -1
  8. package/dist/openbao/bao-http.d.ts +34 -13
  9. package/dist/openbao/bao-http.d.ts.map +1 -1
  10. package/dist/openbao/distilled.d.ts +25 -0
  11. package/dist/openbao/distilled.d.ts.map +1 -0
  12. package/dist/openbao/fake-engines-roles.d.ts +5 -1
  13. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  14. package/dist/openbao/index.js +358 -305
  15. package/dist/openbao/index.js.map +13 -9
  16. package/dist/openbao/policy-identity.d.ts +12 -0
  17. package/dist/openbao/policy-identity.d.ts.map +1 -0
  18. package/dist/openbao/policy-wire.d.ts +9 -8
  19. package/dist/openbao/policy-wire.d.ts.map +1 -1
  20. package/dist/openbao/policy.d.ts.map +1 -1
  21. package/dist/openbao/rename.d.ts +5 -0
  22. package/dist/openbao/rename.d.ts.map +1 -1
  23. package/dist/proxmox/index.js +2 -2
  24. package/dist/proxmox/index.js.map +3 -3
  25. package/dist/proxmox/node-network-wire.d.ts +14 -10
  26. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  27. package/docs/upstream-conformance.md +5 -3
  28. package/package.json +3 -2
  29. package/src/openbao/auth-role-form.test.ts +11 -9
  30. package/src/openbao/auth-role-form.ts +0 -19
  31. package/src/openbao/auth-role-identity.ts +24 -0
  32. package/src/openbao/auth-role-sdk.test.ts +155 -0
  33. package/src/openbao/auth-role-wire.test.ts +128 -0
  34. package/src/openbao/auth-role-wire.ts +89 -0
  35. package/src/openbao/auth-role.ts +9 -9
  36. package/src/openbao/bao-http.ts +9 -7
  37. package/src/openbao/distilled-retry.test.ts +164 -0
  38. package/src/openbao/distilled.test.ts +146 -0
  39. package/src/openbao/distilled.ts +88 -0
  40. package/src/openbao/fake-engines-roles.ts +9 -2
  41. package/src/openbao/policy-distilled.test.ts +98 -0
  42. package/src/openbao/policy-identity.ts +24 -0
  43. package/src/openbao/policy-rename.test.ts +2 -2
  44. package/src/openbao/policy-wire.test.ts +5 -5
  45. package/src/openbao/policy-wire.ts +31 -11
  46. package/src/openbao/policy.ts +10 -13
  47. package/src/openbao/rename.ts +17 -1
  48. package/src/proxmox/node-network-read-failure.test.ts +24 -0
  49. package/src/proxmox/node-network-wire.ts +15 -17
@@ -2,15 +2,20 @@
2
2
  * The three OpenBao calls Bao.Policy makes — read, write and delete of `sys/policies/acl/<name>`.
3
3
  * Split from policy.ts so the trailing-newline round trip can be tested against a fake server.
4
4
  */
5
+ import {
6
+ policiesDeleteAclPolicy,
7
+ policiesReadAclPolicy,
8
+ policiesWriteAclPolicy,
9
+ } from '@distilled.cloud/openbao/policies';
5
10
  import * as Effect from 'effect/Effect';
6
- import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
7
11
  import { BaoError } from './bao-status.ts';
12
+ import { runBao, runBaoRead, runBaoWrite } from './distilled.ts';
8
13
 
9
14
  /** The endpoint the CLI's policy commands used — openbao v2.6.2 api/sys_policy.go:60, :100, :122. */
10
15
  export const policyPath = (name: string) => `sys/policies/acl/${name}`;
11
16
 
12
17
  /**
13
- * The live policy text, or '' when the policy is genuinely absent. Anything else FAILS.
18
+ * The live policy text, or undefined only when the SDK reports PolicyNotFound. Other errors FAIL.
14
19
  *
15
20
  * 🔴 THE ABSENCE WORDING WAS THE POLICY COMMAND'S OWN, AND ASSUMING OTHERWISE MADE A NEW POLICY
16
21
  * UNCREATABLE. `No value found at` was measured for `bao read`; `bao policy read` never printed
@@ -33,21 +38,36 @@ export const policyPath = (name: string) => `sys/policies/acl/${name}`;
33
38
  * ("no policy found in response", api/sys_policy.go:81-85).
34
39
  */
35
40
  export const readPolicy = (name: string) =>
36
- Effect.flatMap(baoRead(policyPath(name)), (data) => {
37
- if (data === undefined) return Effect.succeed('');
38
- const text = data['policy'];
39
- return typeof text === 'string'
40
- ? Effect.succeed(text)
41
- : Effect.fail(new BaoError(200, 'GET', policyPath(name), ['no data.policy in the response']));
42
- });
41
+ runBaoRead(policiesReadAclPolicy({ name })).pipe(
42
+ Effect.flatMap((data) => {
43
+ const text = data['policy'];
44
+ return typeof text === 'string'
45
+ ? Effect.succeed(text)
46
+ : Effect.fail(
47
+ new BaoError(200, 'GET', policyPath(name), ['no data.policy in the response']),
48
+ );
49
+ }),
50
+ Effect.catchTag('PolicyNotFound', () => Effect.succeed(undefined)),
51
+ );
52
+
53
+ /** Even a live empty policy occupies its name; rename guards must not overwrite it. */
54
+ export const policyExists = (name: string) =>
55
+ Effect.map(readPolicy(name), (policy) => policy !== undefined);
43
56
 
44
57
  /**
45
58
  * ★ THE HCL TRAVELS IN THE REQUEST BODY (`{"policy": …}`, api/sys_policy.go:96-101). The CLI path
46
59
  * needed a scoped temp file because passing HCL on argv would have put every grant in the process
47
60
  * list; a JSON body never reaches argv, so the temp file is gone with it.
61
+ * ★ A FULL-REPLACE WRITE: the whole policy text is sent every time, so it is safe to retry a
62
+ * transport failure through `runBaoWrite` — a replayed attempt converges on the same policy
63
+ * rather than compounding a partial one.
48
64
  */
49
65
  export const writePolicy = (name: string, hcl: string) =>
50
- baoWrite('PUT', policyPath(name), { policy: hcl });
66
+ runBaoWrite(policiesWriteAclPolicy({ name, policy: hcl })).pipe(Effect.asVoid);
51
67
 
52
68
  /** Deleting a policy that is already gone answers success (handlePoliciesDelete returns nothing). */
53
- export const deletePolicy = (name: string) => baoDelete(policyPath(name));
69
+ export const deletePolicy = (name: string) =>
70
+ runBao(policiesDeleteAclPolicy({ name })).pipe(
71
+ Effect.catchTag('NotFound', () => Effect.void),
72
+ Effect.asVoid,
73
+ );
@@ -10,9 +10,9 @@ import { ownedRead } from '../ownership/probe.ts';
10
10
  import { provingResumes } from '../ownership/resume.ts';
11
11
  import { sha256 } from './digest.ts';
12
12
  import { isEmptyAssembly } from './policy-assembly.ts';
13
- import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
14
- import { policyKey } from './rename.ts';
15
- import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
13
+ import { judgePolicyRename, policyIdentity } from './policy-identity.ts';
14
+ import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
15
+ import { guardRename } from './rename-identity.ts';
16
16
 
17
17
  /**
18
18
  * An OpenBao ACL policy, assembled from the HCL fragments that declare it.
@@ -103,9 +103,6 @@ export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
103
103
  defaultRemovalPolicy: 'retain',
104
104
  });
105
105
 
106
- /** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
107
- const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
108
-
109
106
  /** `path "..."` grant count — the same number the apply script prints. */
110
107
  const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
111
108
 
@@ -140,12 +137,12 @@ export const BaoPolicyProvider = () =>
140
137
  */
141
138
  list: () => Effect.succeed([]),
142
139
 
143
- // ★ '' means genuinely absent (a 404); a refusal fails. policy-wire.ts has the history.
140
+ // ★ Only undefined means absent (a typed 404); even a live empty policy occupies its name.
144
141
  // ⛔ With no attributes in state, a live policy is `Unowned` unless it is the one our own
145
142
  // interrupted create wrote: these fragments, byte for byte (ownership/probe.ts).
146
143
  read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
147
144
  const live = yield* readPolicy(olds.name);
148
- const found = live.length === 0 ? undefined : attributesOf(olds.name, live, 0);
145
+ const found = live === undefined ? undefined : attributesOf(olds.name, live, 0);
149
146
  const ours = Effect.map(
150
147
  assemble(olds.fragments),
151
148
  (a) => sha256(a.joined) === found?.digest,
@@ -170,7 +167,7 @@ export const BaoPolicyProvider = () =>
170
167
  * delete, so a role outside this graph that names the old policy must move in the same
171
168
  * PR (REPLACE.md).
172
169
  */
173
- const move = yield* judgeRename(IDENTITY, olds, news, output);
170
+ const move = yield* judgePolicyRename(olds, news, output);
174
171
  // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
175
172
  if (output === undefined) return undefined;
176
173
  if (move !== undefined) return { action: 'replace' } as const;
@@ -180,7 +177,7 @@ export const BaoPolicyProvider = () =>
180
177
  if (!isResolved(news)) return undefined;
181
178
  const { joined } = yield* assemble(news.fragments);
182
179
  const live = yield* readPolicy(news.name);
183
- return live.length > 0 && sha256(live) === sha256(joined)
180
+ return live !== undefined && sha256(live) === sha256(joined)
184
181
  ? ({ action: 'noop' } as const)
185
182
  : ({ action: 'update' } as const);
186
183
  }),
@@ -188,7 +185,7 @@ export const BaoPolicyProvider = () =>
188
185
  reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
189
186
  const name = news.name;
190
187
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
191
- yield* guardRename(IDENTITY, news, output);
188
+ yield* guardRename(policyIdentity, news, output);
192
189
  const { joined, parts } = yield* assemble(news.fragments);
193
190
  /**
194
191
  * ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
@@ -218,9 +215,9 @@ export const BaoPolicyProvider = () =>
218
215
  * sibling PVE provider does exactly this in pveOperations.reconcile.
219
216
  */
220
217
  const current = yield* readPolicy(name);
221
- if (current.length > 0)
218
+ if (current !== undefined)
222
219
  yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
223
- if (current.length > 0 && sha256(current) === sha256(joined)) {
220
+ if (current !== undefined && sha256(current) === sha256(joined)) {
224
221
  return attributesOf(name, joined, parts);
225
222
  }
226
223
  // ⚠️ In the request body, never on argv — see `writePolicy` for why the temp file went.
@@ -127,12 +127,28 @@ export const judgeMove = (
127
127
  declared: string | undefined,
128
128
  pathOf: (identity: string) => string,
129
129
  key: (identity: string) => string = exact,
130
+ ) =>
131
+ judgeMoveWith(
132
+ family,
133
+ tried,
134
+ declared,
135
+ (identity) => Effect.map(baoRead(pathOf(identity)), (value) => value !== undefined),
136
+ key,
137
+ );
138
+
139
+ /** SDK families supply their own typed read so a rename cannot fall back to raw HTTP. */
140
+ export const judgeMoveWith = <E, R>(
141
+ family: string,
142
+ tried: string | undefined,
143
+ declared: string | undefined,
144
+ occupied: (identity: string) => Effect.Effect<boolean, E, R>,
145
+ key: (identity: string) => string = exact,
130
146
  ) =>
131
147
  Effect.gen(function* () {
132
148
  if (tried === undefined || declared === undefined || key(tried) === key(declared)) {
133
149
  return undefined;
134
150
  }
135
- if ((yield* baoRead(pathOf(declared))) === undefined) return { from: tried, to: declared };
151
+ if (!(yield* occupied(declared))) return { from: tried, to: declared };
136
152
  return yield* Effect.die(
137
153
  new Error(
138
154
  `${family}: ${tried} → ${declared} would land on an object that already exists at ` +
@@ -139,6 +139,30 @@ describe('only the measured "interface does not exist" 400 means absent', () =>
139
139
  });
140
140
  });
141
141
 
142
+ /**
143
+ * ⛔ THE RETAG REGRESSION'S OWN TEST — RED-TEAM FINDING, 2026-09-25: `@distilled.cloud/proxmox`
144
+ * retagged this exact 400 from the generic `ParameterVerificationFailed` to a specific
145
+ * `NetworkInterfaceNotFound` (`nodes.ts`), and `readInterfaceOrFail`'s `catchTag` still named
146
+ * the OLD tag — so on an already-adopted row (this is `diff`'s and `verify --all`'s `read`'s
147
+ * `output !== undefined` branch, the non-folding one) the absence signal went UNCAUGHT and
148
+ * threw straight through `engine.verify`, instead of resolving a `diff`/`missing` report.
149
+ * Proven by temporarily reverting the `catchTag` to `'ParameterVerificationFailed'`: only this
150
+ * test fails, with an unhandled `NetworkInterfaceNotFound`.
151
+ */
152
+ test('an already-adopted interface whose live GET now answers genuinely absent resolves via diff as missing, never throws', async () => {
153
+ const fake = clusterOverInterface('n2', 'vmbr0', live);
154
+ await withoutBao(async () => {
155
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
156
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
157
+ fake.setAnswer('genuinely-absent');
158
+ const report = await engine.verify(declare(), { all: true });
159
+ // ⛔ `update` IS THE RIGHT ANSWER HERE, NOT A THROW: this resource's `diff` treats a
160
+ // vanished interface as something `reconcile` recreates (node-network.ts's `diff`,
161
+ // `live === undefined` branch), the same as any other drifted-away row.
162
+ expect(report.rows[0]).toMatchObject({ diff: 'update' });
163
+ });
164
+ });
165
+
142
166
  /**
143
167
  * ★ WHY `read` FOLDS AT ALL, SPELLED OUT ON A BRAND NEW DECLARATION. Plan.ts's cold-start
144
168
  * adoption probe (no prior Alchemy state for this declaration) calls `provider.read` with no
@@ -156,14 +156,17 @@ const attributesOf = (
156
156
  * `{"errors":{"iface":"interface does not exist"},"data":null,"message":"Parameter verification
157
157
  * failed.\n"}` at HTTP 400 — a THIRD shape, different from every other migrated family's
158
158
  * measured 500. `@distilled.cloud/proxmox` types this as a genuine typed error,
159
- * `ParameterVerificationFailed` (`errors.ts`), carrying the per-field `errors` object rather
160
- * than collapsing it to an opaque `BadRequest` — PVE's OWN 400 is never one shape (it is
161
- * whatever the endpoint's parameter verification rejected), so `errors.ts`'s own header spells
162
- * out why this ONE typed error is global rather than per-operation.
163
- * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason, a
164
- * permission gap, a network blip, or ANY OTHER `ParameterVerificationFailed` reason is never
165
- * read as "this interface is not there" — `diff` calls this directly and propagates every one
166
- * of them loudly.
159
+ * `NetworkInterfaceNotFound` (`nodes.ts`, generated from `patches/nodes/network-errors.json`),
160
+ * attached ONLY to `getNodeNetwork`'s error union and matched by the SDK's own
161
+ * `errorEnvelope`/`matchTypedError` against the EXACT sole-field body `errors.iface ===
162
+ * "interface does not exist"` — the precision the code here used to check by hand
163
+ * (`error.errors['iface'] === '…'`) is now the SDK's own matcher contract, not this file's.
164
+ * Any 400 that fails that exact match (a different field, a different message, more than one
165
+ * field) decodes as the generic `ParameterVerificationFailed` instead, never this tag.
166
+ * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason
167
+ * (`ParameterVerificationFailed`), a permission gap, a network blip, or any other failure is
168
+ * never read as "this interface is not there" — `diff` calls this directly and propagates
169
+ * every one of them loudly.
167
170
  */
168
171
  export const readInterfaceOrFail = (props: NodeNetworkProps) =>
169
172
  readOrUnreadable(
@@ -172,13 +175,7 @@ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
172
175
  'read',
173
176
  false,
174
177
  nodes.getNodeNetwork({ iface: props.iface, node: props.node }),
175
- ).pipe(
176
- Effect.catchTag('ParameterVerificationFailed', (error) =>
177
- error.errors['iface'] === 'interface does not exist'
178
- ? Effect.succeed(undefined)
179
- : Effect.fail(error),
180
- ),
181
- ),
178
+ ).pipe(Effect.catchTag('NetworkInterfaceNotFound', () => Effect.succeed(undefined))),
182
179
  ).pipe(
183
180
  Effect.map((live) => {
184
181
  if (live === UNREADABLE) return UNREADABLE;
@@ -198,8 +195,9 @@ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
198
195
  * interrupted-create recovery, and Apply.ts's delete recovery, all call `read` with
199
196
  * `output: undefined` and NO catch of their own (`Plan.ts` v2.0.0-beta.79 ~line 1308) — and
200
197
  * Plan.ts fails the WHOLE PLAN, every other resource included, if that call throws. A malformed
201
- * `iface` in ONE brand-new declaration (a DIFFERENT `ParameterVerificationFailed` than the
202
- * absence one) would abort `bun run plan` for the entire stack under the precise-only design,
198
+ * `iface` in ONE brand-new declaration (a `ParameterVerificationFailed`, DIFFERENT from the
199
+ * `NetworkInterfaceNotFound` absence tag) would abort `bun run plan` for the entire stack under
200
+ * the precise-only design,
203
201
  * where every other migrated family instead defers that same malformed declaration to a scoped,
204
202
  * loud refusal at apply. This restores that same fold for those three call sites.
205
203
  */