@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
@@ -11,6 +11,7 @@
11
11
  * ⚠️ THE STACK MUST PROVIDE `FetchHttpClient.layer`. alchemy.run.ts does, the way
12
12
  * <estate>/proxmox/alchemy.run.ts does.
13
13
  */
14
+ import * as Cause from 'effect/Cause';
14
15
  import * as Context from 'effect/Context';
15
16
  import * as Effect from 'effect/Effect';
16
17
  import * as Option from 'effect/Option';
@@ -65,7 +66,7 @@ export const BaoGate = Context.Reference<Semaphore.Semaphore>('homeflare/openbao
65
66
  * ⚠️ THE CLI's DEFAULT CLIENT TIMEOUT (openbao v2.6.2 api/client.go:324). A hung agent or a server
66
67
  * that accepts and never answers would otherwise hang the plan with no line saying why.
67
68
  */
68
- const TIMEOUT = '60 seconds';
69
+ export const BAO_TIMEOUT = '60 seconds';
69
70
 
70
71
  /**
71
72
  * Mesh in front of a remote OpenBao drops connections under Alchemy's unbounded
@@ -73,11 +74,12 @@ const TIMEOUT = '60 seconds';
73
74
  * 2026-09-16 against bao.example.internal: a 585-role plan died mid-diff with
74
75
  * `no response: (no errors given)` while the vault stayed unsealed.
75
76
  */
76
- const retryTransport = <A, R>(effect: Effect.Effect<A, BaoError, R>) =>
77
+ export const retryTransport = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
77
78
  Effect.retry(effect, {
78
79
  schedule: Schedule.spaced('750 millis'),
79
80
  times: 2,
80
- while: (error: BaoError) => error.status === 0,
81
+ while: (error) =>
82
+ (error instanceof BaoError && error.status === 0) || Cause.isTimeoutError(error),
81
83
  });
82
84
 
83
85
  /**
@@ -87,7 +89,7 @@ const retryTransport = <A, R>(effect: Effect.Effect<A, BaoError, R>) =>
87
89
  * true). `X-Vault-Token` is not on that list, so under any tracer the token would be exported as
88
90
  * a span attribute. It is added for every call made here.
89
91
  */
90
- const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
92
+ export const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
91
93
  Effect.gen(function* () {
92
94
  const names = yield* Headers.CurrentRedactedNames;
93
95
  return yield* Effect.provideService(effect, Headers.CurrentRedactedNames, [
@@ -109,9 +111,9 @@ const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
109
111
  * the request to http://localhost:80 — a silent wrong server, which is the one outcome this
110
112
  * package exists to prevent. It also assumes the provided `HttpClient` is the fetch one.
111
113
  */
112
- const overSocket =
114
+ export const overSocket =
113
115
  (socket: string | undefined, method: string, path: string) =>
114
- <A, R>(effect: Effect.Effect<A, BaoError, R>): Effect.Effect<A, BaoError, R> => {
116
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | BaoError, R> => {
115
117
  if (socket === undefined) return effect;
116
118
  if (typeof Bun === 'undefined') {
117
119
  return Effect.fail(
@@ -148,7 +150,7 @@ export const baoCall = (
148
150
  Effect.flatMap((response) =>
149
151
  Effect.map(response.text, (text) => ({ status: response.status, text })),
150
152
  ),
151
- Effect.timeout(TIMEOUT),
153
+ Effect.timeout(BAO_TIMEOUT),
152
154
  // ⛔ `.message` ONLY: the HttpClientError message is the reason, method and URL. The error
153
155
  // object itself holds the request, whose headers hold the token.
154
156
  Effect.mapError((cause) => new BaoError(0, method, path, [cause.message])),
@@ -0,0 +1,164 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { policiesReadAclPolicy } from '@distilled.cloud/openbao/policies';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
5
+ import { HttpClientError, TransportError } from 'effect/unstable/http/HttpClientError';
6
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
7
+ import { readAuthRole, writeAuthRole } from './auth-role-wire.ts';
8
+ import { BaoEnv } from './bao-http.ts';
9
+ import { BaoError } from './bao-status.ts';
10
+ import { runBaoRead } from './distilled.ts';
11
+ import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
12
+
13
+ const ROLE_PROPS = {
14
+ name: 'test',
15
+ tokenPolicies: ['default'],
16
+ tokenTtl: '15m',
17
+ tokenMaxTtl: '1h',
18
+ secretIdTtl: '2160h',
19
+ };
20
+
21
+ const TOKEN = 'fixture-retry-token';
22
+ const POLICY = { policy: 'path "test" {}' };
23
+ const ROLE = {
24
+ bind_secret_id: true,
25
+ secret_id_num_uses: 0,
26
+ secret_id_ttl: 0,
27
+ token_max_ttl: 3600,
28
+ token_policies: ['default'],
29
+ token_ttl: 900,
30
+ };
31
+
32
+ const runWith = <A, E>(
33
+ effect: Effect.Effect<A, E, HttpClient.HttpClient>,
34
+ client: HttpClient.HttpClient,
35
+ ) =>
36
+ Effect.runPromise(
37
+ effect.pipe(
38
+ Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid', BAO_TOKEN: TOKEN }),
39
+ Effect.provideService(HttpClient.HttpClient, client),
40
+ ),
41
+ );
42
+
43
+ for (const [name, read, data] of [
44
+ ['Policy', Effect.asVoid(readPolicy('test')), POLICY],
45
+ ['AppRole', Effect.asVoid(readAuthRole('test')), ROLE],
46
+ ] as const) {
47
+ test(`${name} retries a dropped read and then returns the live object`, async () => {
48
+ let attempts = 0;
49
+ const client = HttpClient.make((request) => {
50
+ attempts++;
51
+ expect(request.method).toBe('GET');
52
+ return attempts === 1
53
+ ? Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }))
54
+ : Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data })));
55
+ });
56
+ await runWith(read, client);
57
+ expect(attempts).toBe(2);
58
+ });
59
+ }
60
+
61
+ test('read transport retries stop after three attempts without exposing the token', async () => {
62
+ let attempts = 0;
63
+ const client = HttpClient.make((request) => {
64
+ attempts++;
65
+ return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
66
+ });
67
+ const error = await runWith(Effect.flip(readPolicy('test')), client);
68
+ expect(attempts).toBe(3);
69
+ expect(error).toBeInstanceOf(BaoError);
70
+ expect(JSON.stringify(error)).not.toContain(TOKEN);
71
+ });
72
+
73
+ /**
74
+ * A FULL-REPLACE write sends the complete desired state on every attempt, so — unlike a
75
+ * create or a non-idempotent call — it is safe to replay after a transport-level failure.
76
+ * `runBaoWrite` restores the bounded retry `baoCall()` gave every call before the distilled
77
+ * SDK path landed with `Retry.none`.
78
+ */
79
+ for (const [name, write] of [
80
+ ['Policy', writePolicy('test', 'path "test" {}')],
81
+ ['AppRole', writeAuthRole(ROLE_PROPS)],
82
+ ] as const) {
83
+ test(`${name} write retries a dropped transport attempt and then succeeds`, async () => {
84
+ let attempts = 0;
85
+ const client = HttpClient.make((request) => {
86
+ attempts++;
87
+ expect(request.method).toBe('POST');
88
+ return attempts === 1
89
+ ? Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }))
90
+ : Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })));
91
+ });
92
+ await runWith(write, client);
93
+ expect(attempts).toBe(2);
94
+ });
95
+
96
+ test(`${name} write transport retries stop after three attempts without exposing the token`, async () => {
97
+ let attempts = 0;
98
+ const client = HttpClient.make((request) => {
99
+ attempts++;
100
+ return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
101
+ });
102
+ const error = await runWith(Effect.flip(write), client);
103
+ expect(attempts).toBe(3);
104
+ expect(error).toBeInstanceOf(BaoError);
105
+ expect(JSON.stringify(error)).not.toContain(TOKEN);
106
+ });
107
+ }
108
+
109
+ test('a read timeout has a fresh bound on its next attempt', async () => {
110
+ let attempts = 0;
111
+ const client = HttpClient.make((request) => {
112
+ attempts++;
113
+ return attempts === 1
114
+ ? Effect.never
115
+ : Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: POLICY })));
116
+ });
117
+ await runWith(runBaoRead(policiesReadAclPolicy({ name: 'test' }), '20 millis'), client);
118
+ expect(attempts).toBe(2);
119
+ });
120
+
121
+ /**
122
+ * Delete stays single-attempt: it is idempotent (a policy or role already gone is success,
123
+ * per policy-wire.ts / auth-role-wire.ts), but it is not a full-replace write, and this suite
124
+ * scopes the restored retry to the writes that carry the complete desired state on the wire.
125
+ */
126
+ test('an uncertain delete is never replayed', async () => {
127
+ let attempts = 0;
128
+ const client = HttpClient.make((request) => {
129
+ attempts++;
130
+ return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
131
+ });
132
+ const error = await runWith(Effect.flip(deletePolicy('test')), client);
133
+ expect(error).toBeInstanceOf(BaoError);
134
+ expect(attempts).toBe(1);
135
+ });
136
+
137
+ test('typed denied reads are returned immediately without transport retries', async () => {
138
+ let attempts = 0;
139
+ const client = HttpClient.make((request) => {
140
+ attempts++;
141
+ return Effect.succeed(
142
+ HttpClientResponse.fromWeb(
143
+ request,
144
+ Response.json({ errors: ['permission denied'] }, { status: 403 }),
145
+ ),
146
+ );
147
+ });
148
+ const error = await runWith(Effect.flip(readPolicy('test')), client);
149
+ expect(error).toMatchObject({ _tag: 'Forbidden' });
150
+ expect(attempts).toBe(1);
151
+ });
152
+
153
+ test('SDK normalization accepts an address that already carries the API prefix', async () => {
154
+ let observed = '';
155
+ const client = HttpClient.make((request) => {
156
+ observed = request.url;
157
+ return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: POLICY })));
158
+ });
159
+ await runWith(
160
+ readPolicy('test').pipe(Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid/v1/' })),
161
+ client,
162
+ );
163
+ expect(observed).toBe('http://fake.invalid/v1/sys/policies/acl/test');
164
+ });
@@ -0,0 +1,146 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { policiesReadAclPolicy, policiesWriteAclPolicy } from '@distilled.cloud/openbao/policies';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Semaphore from 'effect/Semaphore';
8
+ import * as Headers from 'effect/unstable/http/Headers';
9
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
10
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
11
+ import { BaoEnv, BaoGate, MAX_IN_FLIGHT, baoRead } from './bao-http.ts';
12
+ import { runBao } from './distilled.ts';
13
+ import { fakeBao, run, runFailure, withFake } from './fake-bao.ts';
14
+ import { BaoError } from './bao-status.ts';
15
+ import { readPolicy } from './policy-wire.ts';
16
+
17
+ const OK = { json: { data: { policy: 'path "test" {}' } }, status: 200 };
18
+
19
+ test('SDK calls resolve BAO/VAULT values per call, retaining empty BAO precedence', async () => {
20
+ await withFake(
21
+ () => OK,
22
+ async (bao) => {
23
+ const env = {
24
+ VAULT_ADDR: bao.address,
25
+ VAULT_NAMESPACE: 'tenant',
26
+ VAULT_TOKEN: 'first',
27
+ BAO_TOKEN: '',
28
+ };
29
+ const read = readPolicy('test');
30
+ await run(env, read);
31
+ env.BAO_TOKEN = 'rotated';
32
+ await run(env, read);
33
+ expect(bao.seen[0]?.headers.has('x-vault-token')).toBe(false);
34
+ expect(bao.seen[1]?.headers.get('x-vault-token')).toBe('rotated');
35
+ expect(bao.seen[1]?.headers.get('x-vault-namespace')).toBe('tenant');
36
+ expect(bao.seen[1]?.headers.get('x-vault-request')).toBe('true');
37
+ },
38
+ );
39
+ });
40
+
41
+ test('SDK calls use the agent unix socket ahead of a direct address, without a token header', async () => {
42
+ const socket = join(tmpdir(), `distilled-bao-${process.pid}.sock`);
43
+ rmSync(socket, { force: true });
44
+ try {
45
+ await withFake(
46
+ () => OK,
47
+ async (bao) => {
48
+ await run(
49
+ { BAO_ADDR: 'http://wrong.invalid', BAO_AGENT_ADDR: bao.address },
50
+ readPolicy('test'),
51
+ );
52
+ expect(bao.seen[0]?.path).toBe('/v1/sys/policies/acl/test');
53
+ expect(bao.seen[0]?.headers.has('x-vault-token')).toBe(false);
54
+ expect(bao.seen[0]?.headers.has('x-vault-namespace')).toBe(false);
55
+ },
56
+ socket,
57
+ );
58
+ } finally {
59
+ rmSync(socket, { force: true });
60
+ }
61
+ });
62
+
63
+ test('legacy and SDK calls share the same eight in-flight permits', async () => {
64
+ let active = 0;
65
+ let peak = 0;
66
+ await withFake(
67
+ async () => {
68
+ active++;
69
+ peak = Math.max(peak, active);
70
+ await Bun.sleep(15);
71
+ active--;
72
+ return OK;
73
+ },
74
+ async (bao) => {
75
+ await run(
76
+ { BAO_ADDR: bao.address },
77
+ Effect.all(
78
+ Array.from({ length: 32 }, (_, i) =>
79
+ i % 2 === 0 ? Effect.asVoid(readPolicy(`p${i}`)) : Effect.asVoid(baoRead(`p${i}`)),
80
+ ),
81
+ { concurrency: 'unbounded' },
82
+ ),
83
+ );
84
+ expect(peak).toBe(MAX_IN_FLIGHT);
85
+ expect(bao.seen).toHaveLength(32);
86
+ },
87
+ );
88
+ });
89
+
90
+ test('a sent write is bounded, not retried, and releases its permit', async () => {
91
+ await withFake(
92
+ async ({ method }) => {
93
+ if (method === 'POST') {
94
+ await Bun.sleep(120);
95
+ return { status: 204 };
96
+ }
97
+ return OK;
98
+ },
99
+ async (bao) => {
100
+ const gate = Semaphore.makeUnsafe(1);
101
+ const write = runBao(
102
+ policiesWriteAclPolicy({ name: 'test', policy: 'path "test" {}' }),
103
+ '30 millis',
104
+ );
105
+ const error = await runFailure(
106
+ { BAO_ADDR: bao.address },
107
+ write.pipe(Effect.provideService(BaoGate, gate)),
108
+ );
109
+ expect(error).toMatchObject({ _tag: 'TimeoutError' });
110
+ await run(
111
+ { BAO_ADDR: bao.address },
112
+ readPolicy('test').pipe(Effect.provideService(BaoGate, gate)),
113
+ );
114
+ expect(bao.seen.filter(({ method }) => method === 'POST')).toHaveLength(1);
115
+ },
116
+ );
117
+ });
118
+
119
+ test('the SDK keeps x-vault-token in Effect trace redaction alongside default names', async () => {
120
+ let names: ReadonlyArray<RegExp | string> = [];
121
+ const client = HttpClient.make((request, _url, _signal, fiber) => {
122
+ names = fiber.getRef(Headers.CurrentRedactedNames);
123
+ return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(OK.json)));
124
+ });
125
+ await Effect.runPromise(
126
+ runBao(policiesReadAclPolicy({ name: 'test' })).pipe(
127
+ Effect.provideService(BaoEnv, {
128
+ BAO_ADDR: 'http://fake.invalid',
129
+ BAO_TOKEN: 'never-log-this',
130
+ }),
131
+ Effect.provideService(HttpClient.HttpClient, client),
132
+ ),
133
+ );
134
+ expect(names).toContain('x-vault-token');
135
+ expect(names).toContain('authorization');
136
+ });
137
+
138
+ test('transport failures cannot serialize the request token after the fiber context unwinds', async () => {
139
+ const gone = fakeBao(() => OK);
140
+ gone.stop();
141
+ const token = 'fixture-private-bao-token';
142
+ const error = await runFailure({ BAO_ADDR: gone.address, BAO_TOKEN: token }, readPolicy('test'));
143
+ expect(error).toBeInstanceOf(BaoError);
144
+ expect(JSON.stringify(error)).not.toContain(token);
145
+ expect(String(error)).not.toContain(token);
146
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The SDK shares Bao's existing call-time environment and process-wide concurrency gate.
3
+ * Merely supplying HttpClient does not apply that gate: legacy calls acquire it in baoCall.
4
+ * ⛔ `runBao` ITSELF NEVER RETRIES: an interrupted call may already have reached the vault, and
5
+ * replaying a non-idempotent operation (a delete whose 404-is-success path already ran, a login,
6
+ * a credential issuance) could repeat a side effect. `runBaoRead` and `runBaoWrite` opt specific,
7
+ * known-idempotent call shapes back into the bounded transport retry baoCall always had.
8
+ */
9
+ import { Credentials, normalizeBaseUrl } from '@distilled.cloud/openbao/Credentials';
10
+ import type { OpenBaoOpContext } from '@distilled.cloud/openbao/Protocol';
11
+ import * as Retry from '@distilled.cloud/openbao/Retry';
12
+ import type * as Cause from 'effect/Cause';
13
+ import type * as Duration from 'effect/Duration';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Redacted from 'effect/Redacted';
16
+ import * as Semaphore from 'effect/Semaphore';
17
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
+ import { isHttpClientError } from 'effect/unstable/http/HttpClientError';
19
+ import { readBaoVariable, resolveAddress } from './bao-address.ts';
20
+ import {
21
+ BAO_TIMEOUT,
22
+ BaoEnv,
23
+ BaoGate,
24
+ overSocket,
25
+ redactingToken,
26
+ retryTransport,
27
+ } from './bao-http.ts';
28
+ import { BaoError } from './bao-status.ts';
29
+
30
+ export const runBao = <A, E>(
31
+ operation: Effect.Effect<A, E, OpenBaoOpContext>,
32
+ timeout: Duration.Input = BAO_TIMEOUT,
33
+ ): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
34
+ Effect.gen(function* () {
35
+ const env = yield* BaoEnv;
36
+ const address = resolveAddress(env);
37
+ const token = readBaoVariable(env, 'BAO_TOKEN');
38
+ const gate = yield* BaoGate;
39
+ return yield* operation.pipe(
40
+ Effect.provideService(
41
+ Credentials,
42
+ Effect.succeed({
43
+ apiBaseUrl: normalizeBaseUrl(address.base),
44
+ ...(address.namespace === undefined ? {} : { namespace: address.namespace }),
45
+ ...(token === '' ? {} : { token: Redacted.make(token) }),
46
+ }),
47
+ ),
48
+ Retry.none,
49
+ // ⛔ HttpClientError holds the request, including X-Vault-Token. Trace redaction is
50
+ // fiber-local and gone when an error is serialized later; keep only its safe message,
51
+ // as baoCall does. SDK status/resource errors retain their original typed identity.
52
+ Effect.mapError((cause) =>
53
+ isHttpClientError(cause) ? new BaoError(0, 'SDK', 'operation', [cause.message]) : cause,
54
+ ),
55
+ // ★ A queued call has not reached the server: acquire the shared permit outside the timeout.
56
+ Effect.timeout(timeout),
57
+ overSocket(address.socket, 'SDK', 'operation'),
58
+ redactingToken,
59
+ Semaphore.withPermits(gate, 1),
60
+ );
61
+ });
62
+
63
+ /**
64
+ * Reads use the existing two transport retries (750 ms between attempts). Each attempt
65
+ * reacquires the shared permit and its own bounded timeout. Typed SDK status and decoding
66
+ * failures still propagate immediately; a read is always safe to replay because it never
67
+ * changes vault state.
68
+ */
69
+ export const runBaoRead = <A, E>(
70
+ operation: Effect.Effect<A, E, OpenBaoOpContext>,
71
+ timeout: Duration.Input = BAO_TIMEOUT,
72
+ ): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
73
+ retryTransport(runBao(operation, timeout));
74
+
75
+ /**
76
+ * The pre-PR `baoCall()` retried every call, writes included; that bound was lost when the
77
+ * distilled SDK path landed with `Retry.none` on `runBao`. Restore it for the writes where
78
+ * replaying is safe: a FULL-REPLACE, idempotent write (an OpenBao policy PUT, an AppRole
79
+ * write) sends the complete desired state every time, so retrying an uncertain transport
80
+ * failure converges on the same result rather than compounding a partial change. A create
81
+ * that mints a new identifier, or any operation whose second attempt could do something the
82
+ * first did not, must keep calling `runBao` directly and make one attempt.
83
+ */
84
+ export const runBaoWrite = <A, E>(
85
+ operation: Effect.Effect<A, E, OpenBaoOpContext>,
86
+ timeout: Duration.Input = BAO_TIMEOUT,
87
+ ): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
88
+ retryTransport(runBao(operation, timeout));
@@ -48,10 +48,17 @@ const typed = (body: Stored): Stored => {
48
48
  return out;
49
49
  };
50
50
 
51
- /** Every role engine, JWT auth config and MFA login enforcement. A write replaces the object. */
51
+ /**
52
+ * Every role engine, JWT auth config and MFA login enforcement.
53
+ * ★ AppRole preserves omitted knobs and supplies defaults on create (OpenBao 2.6.2
54
+ * path_role.go:1688-1721); this fixture must not turn a correct omitted SDK field into drift.
55
+ */
52
56
  export const wireRoles = (): Store =>
53
57
  store<Stored>(
54
- (_path, body) => typed(body),
58
+ (path, body, before) =>
59
+ path.startsWith('auth/approle/role/')
60
+ ? { bind_secret_id: true, secret_id_num_uses: 0, ...before, ...typed(body) }
61
+ : typed(body),
55
62
  (path) => keyOf(path),
56
63
  );
57
64
 
@@ -0,0 +1,98 @@
1
+ import { expect, test } from 'bun:test';
2
+ import * as Effect from 'effect/Effect';
3
+ import { runFailure, withFake } from './fake-bao.ts';
4
+ import { aclPolicies, fakePolicyProviders } from './fake-engines.ts';
5
+ import { withFakeStack, writesOf } from './fake-stack.ts';
6
+ import { BaoPolicy } from './policy.ts';
7
+ import { judgePolicyRename } from './policy-identity.ts';
8
+ import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
9
+
10
+ for (const [status, tag] of [
11
+ [401, 'Unauthorized'],
12
+ [403, 'Forbidden'],
13
+ [503, 'ServiceUnavailable'],
14
+ ] as const) {
15
+ test(`SDK policy ${status} failures propagate on every verb without retry`, async () => {
16
+ await withFake(
17
+ () => ({ status, json: { errors: ['refused'] } }),
18
+ async (bao) => {
19
+ const env = { BAO_ADDR: bao.address };
20
+ for (const op of [
21
+ Effect.asVoid(readPolicy('test')),
22
+ writePolicy('test', 'path "test" {}'),
23
+ deletePolicy('test'),
24
+ ]) {
25
+ expect(await runFailure(env, op)).toMatchObject({ _tag: tag });
26
+ }
27
+ expect(bao.seen.map(({ method }) => method)).toEqual(['GET', 'POST', 'DELETE']);
28
+ },
29
+ );
30
+ });
31
+ }
32
+
33
+ test('SDK policy refuses null, non-string, and missing policy text on a successful read', async () => {
34
+ for (const data of [null, {}, { policy: 42 }]) {
35
+ await withFake(
36
+ () => ({ status: 200, json: { data } }),
37
+ async (bao) => {
38
+ const error = await runFailure({ BAO_ADDR: bao.address }, readPolicy('test'));
39
+ expect(String(error)).toContain('no data.policy');
40
+ },
41
+ );
42
+ }
43
+ });
44
+
45
+ test('rename checks preserve an occupied empty policy and propagate denied SDK reads', async () => {
46
+ await withFake(
47
+ () => ({ status: 200, json: { data: { policy: '' } } }),
48
+ async (bao) => {
49
+ await expect(
50
+ runFailure(
51
+ { BAO_ADDR: bao.address },
52
+ judgePolicyRename({ name: 'old' }, { name: 'new' }, undefined),
53
+ ),
54
+ ).rejects.toThrow('already exists');
55
+ },
56
+ );
57
+ await withFake(
58
+ () => ({ status: 403, json: { errors: ['refused'] } }),
59
+ async (bao) => {
60
+ expect(
61
+ await runFailure(
62
+ { BAO_ADDR: bao.address },
63
+ judgePolicyRename({ name: 'old' }, { name: 'new' }, undefined),
64
+ ),
65
+ ).toMatchObject({ _tag: 'Forbidden' });
66
+ },
67
+ );
68
+ });
69
+
70
+ test('policy adoption and next deploy write nothing, while live drift writes one SDK POST', async () => {
71
+ const store = aclPolicies();
72
+ const path = 'sys/policies/acl/test';
73
+ store.live.set(path, {
74
+ name: 'test',
75
+ policy: 'path "/policies/test/a.hcl" {\n capabilities = ["read"]\n}',
76
+ });
77
+ await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
78
+ const body = BaoPolicy('Policy', { name: 'test', fragments: '/policies/test' });
79
+ expect(await stack.deploy(body, { adopt: true })).toEqual({ Policy: 'adopted' });
80
+ expect(writesOf(bao.seen)).toEqual([]);
81
+ expect(await stack.deploy(body)).toEqual({ Policy: 'noop' });
82
+ store.live.set(path, { name: 'test', policy: 'path "drift" {}' });
83
+ bao.seen.length = 0;
84
+ expect(await stack.deploy(body)).toEqual({ Policy: 'update' });
85
+ expect(writesOf(bao.seen)).toEqual(['POST /v1/sys/policies/acl/test']);
86
+ });
87
+ });
88
+
89
+ test('a real empty policy is an existing unowned object, never a first-time create', async () => {
90
+ const store = aclPolicies();
91
+ store.live.set('sys/policies/acl/test', { name: 'test', policy: '' });
92
+ await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
93
+ await expect(
94
+ stack.deploy(BaoPolicy('Policy', { name: 'test', fragments: '/policies/test' })),
95
+ ).rejects.toThrow('not owned');
96
+ expect(writesOf(bao.seen)).toEqual([]);
97
+ });
98
+ });
@@ -0,0 +1,24 @@
1
+ import { policyExists, policyPath } from './policy-wire.ts';
2
+ import { declaredString, judgeMoveWith, policyKey } from './rename.ts';
3
+ import { nameIdentity } from './rename-identity.ts';
4
+
5
+ /** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
6
+ export const policyIdentity = nameIdentity<{ readonly name: string }>(
7
+ 'Bao.Policy',
8
+ policyPath,
9
+ policyKey,
10
+ );
11
+
12
+ /** The occupied-name guard must use the same SDK and refusal semantics as every other read. */
13
+ export const judgePolicyRename = (
14
+ olds: unknown,
15
+ news: unknown,
16
+ output: { readonly name: string } | undefined,
17
+ ) =>
18
+ judgeMoveWith(
19
+ 'Bao.Policy',
20
+ output === undefined ? declaredString(olds, 'name') : output.name,
21
+ declaredString(news, 'name'),
22
+ policyExists,
23
+ policyKey,
24
+ );
@@ -38,7 +38,7 @@ describe('Bao.Policy rename, through the engine', () => {
38
38
  Policy: 'replace',
39
39
  });
40
40
  assert.deepEqual(writes(bao.seen), [
41
- 'PUT /v1/sys/policies/acl/app-new',
41
+ 'POST /v1/sys/policies/acl/app-new',
42
42
  'DELETE /v1/sys/policies/acl/app-old',
43
43
  ]);
44
44
  assert.deepEqual(live(), ['app-new']);
@@ -101,7 +101,7 @@ describe('Bao.Policy rename, through the engine', () => {
101
101
  stack.deploy(body('/policies/two')),
102
102
  /identity moved from app-1 to app-2/,
103
103
  );
104
- assert.deepEqual(writes(bao.seen), ['PUT /v1/sys/policies/acl/upstream']);
104
+ assert.deepEqual(writes(bao.seen), ['POST /v1/sys/policies/acl/upstream']);
105
105
  assert.deepEqual(await stack.deploy(body('/policies/two')), {
106
106
  Policy: 'replace',
107
107
  Upstream: 'noop',
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import assert from 'node:assert/strict';
7
7
  import { describe, it } from 'node:test';
8
- import { BaoError } from './bao-status.ts';
8
+ import { Forbidden } from '@distilled.cloud/openbao/Errors';
9
9
  import { sha256 } from './digest.ts';
10
10
  import { run, runFailure, withFake } from './fake-bao.ts';
11
11
  import { aclPolicies } from './fake-engines.ts';
@@ -19,7 +19,7 @@ const ASSEMBLED = 'path "kv/data/a" {\n capabilities = ["read"]\n}\n' + 'path "
19
19
  describe('policy wire', () => {
20
20
  it('reads a policy that was never written as absent', async () => {
21
21
  await withFake(aclPolicies(), async (bao) => {
22
- assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), '');
22
+ assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), undefined);
23
23
  });
24
24
  });
25
25
 
@@ -29,10 +29,11 @@ describe('policy wire', () => {
29
29
  await withFake(aclPolicies(), async (bao) => {
30
30
  const env = { BAO_ADDR: bao.address };
31
31
  await run(env, writePolicy('app-llm', ASSEMBLED));
32
- assert.equal(bao.seen[0]?.method, 'PUT');
32
+ assert.equal(bao.seen[0]?.method, 'POST');
33
33
  assert.equal(bao.seen[0]?.path, `${PREFIX}app-llm`);
34
34
  assert.deepEqual(JSON.parse(bao.seen[0]?.body ?? ''), { policy: ASSEMBLED });
35
35
  const live = await run(env, readPolicy('app-llm'));
36
+ assert.ok(live !== undefined);
36
37
  assert.notEqual(live, ASSEMBLED, 'the fake strips the newline, as OpenBao does');
37
38
  assert.equal(sha256(live), sha256(ASSEMBLED));
38
39
  });
@@ -43,8 +44,7 @@ describe('policy wire', () => {
43
44
  () => ({ json: { errors: ['permission denied'] }, status: 403 }),
44
45
  async (bao) => {
45
46
  const error = await runFailure({ BAO_ADDR: bao.address }, readPolicy('homeflare-admin'));
46
- assert.ok(error instanceof BaoError);
47
- assert.equal(error.status, 403);
47
+ assert.ok(error instanceof Forbidden);
48
48
  },
49
49
  );
50
50
  });