@homeflare/alchemy 0.37.4 → 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 (62) hide show
  1. package/dist/linux/directory-read.d.ts +14 -0
  2. package/dist/linux/directory-read.d.ts.map +1 -0
  3. package/dist/linux/directory.d.ts.map +1 -1
  4. package/dist/linux/index.js +22 -6
  5. package/dist/linux/index.js.map +5 -4
  6. package/dist/litellm/index.js +12 -3
  7. package/dist/litellm/index.js.map +3 -3
  8. package/dist/litellm/operations.d.ts.map +1 -1
  9. package/dist/openbao/auth-role-form.d.ts +0 -6
  10. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  11. package/dist/openbao/auth-role-identity.d.ts +11 -0
  12. package/dist/openbao/auth-role-identity.d.ts.map +1 -0
  13. package/dist/openbao/auth-role-wire.d.ts +23 -0
  14. package/dist/openbao/auth-role-wire.d.ts.map +1 -0
  15. package/dist/openbao/auth-role.d.ts.map +1 -1
  16. package/dist/openbao/bao-http.d.ts +34 -13
  17. package/dist/openbao/bao-http.d.ts.map +1 -1
  18. package/dist/openbao/distilled.d.ts +25 -0
  19. package/dist/openbao/distilled.d.ts.map +1 -0
  20. package/dist/openbao/fake-engines-roles.d.ts +5 -1
  21. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  22. package/dist/openbao/index.js +358 -305
  23. package/dist/openbao/index.js.map +13 -9
  24. package/dist/openbao/policy-identity.d.ts +12 -0
  25. package/dist/openbao/policy-identity.d.ts.map +1 -0
  26. package/dist/openbao/policy-wire.d.ts +9 -8
  27. package/dist/openbao/policy-wire.d.ts.map +1 -1
  28. package/dist/openbao/policy.d.ts.map +1 -1
  29. package/dist/openbao/rename.d.ts +5 -0
  30. package/dist/openbao/rename.d.ts.map +1 -1
  31. package/dist/proxmox/index.js +2 -2
  32. package/dist/proxmox/index.js.map +3 -3
  33. package/dist/proxmox/node-network-wire.d.ts +14 -10
  34. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  35. package/docs/upstream-conformance.md +5 -3
  36. package/package.json +3 -2
  37. package/src/linux/directory-read.ts +24 -0
  38. package/src/linux/directory.test.ts +20 -0
  39. package/src/linux/directory.ts +21 -11
  40. package/src/litellm/operations.ts +31 -4
  41. package/src/litellm/pass-through-fetch-client.test.ts +40 -0
  42. package/src/openbao/auth-role-form.test.ts +11 -9
  43. package/src/openbao/auth-role-form.ts +0 -19
  44. package/src/openbao/auth-role-identity.ts +24 -0
  45. package/src/openbao/auth-role-sdk.test.ts +155 -0
  46. package/src/openbao/auth-role-wire.test.ts +128 -0
  47. package/src/openbao/auth-role-wire.ts +89 -0
  48. package/src/openbao/auth-role.ts +9 -9
  49. package/src/openbao/bao-http.ts +9 -7
  50. package/src/openbao/distilled-retry.test.ts +164 -0
  51. package/src/openbao/distilled.test.ts +146 -0
  52. package/src/openbao/distilled.ts +88 -0
  53. package/src/openbao/fake-engines-roles.ts +9 -2
  54. package/src/openbao/policy-distilled.test.ts +98 -0
  55. package/src/openbao/policy-identity.ts +24 -0
  56. package/src/openbao/policy-rename.test.ts +2 -2
  57. package/src/openbao/policy-wire.test.ts +5 -5
  58. package/src/openbao/policy-wire.ts +31 -11
  59. package/src/openbao/policy.ts +10 -13
  60. package/src/openbao/rename.ts +17 -1
  61. package/src/proxmox/node-network-read-failure.test.ts +24 -0
  62. package/src/proxmox/node-network-wire.ts +15 -17
@@ -0,0 +1,128 @@
1
+ import { expect, test } from 'bun:test';
2
+ import * as Effect from 'effect/Effect';
3
+ import { judgeAuthRoleRename } from './auth-role-identity.ts';
4
+ import { deleteAuthRole, readAuthRole, writeAuthRole } from './auth-role-wire.ts';
5
+ import { run, runFailure, withFake } from './fake-bao.ts';
6
+
7
+ const PROPS = {
8
+ name: 'host-cert',
9
+ tokenPolicies: ['host-cert'],
10
+ tokenTtl: '15m',
11
+ tokenMaxTtl: '1h',
12
+ secretIdTtl: '2160h',
13
+ };
14
+ const DATA = {
15
+ bind_secret_id: true,
16
+ secret_id_num_uses: 0,
17
+ secret_id_ttl: 7776000,
18
+ token_max_ttl: 3600,
19
+ token_policies: ['host-cert'],
20
+ token_ttl: 900,
21
+ };
22
+
23
+ test('metadata SDK POST preserves omission/false/zero and never sends credential IDs', async () => {
24
+ await withFake(
25
+ () => ({ status: 204 }),
26
+ async (bao) => {
27
+ const env = { BAO_ADDR: bao.address, BAO_NAMESPACE: 'fixture', BAO_TOKEN: 'fake-token' };
28
+ await run(env, writeAuthRole(PROPS));
29
+ await run(env, writeAuthRole({ ...PROPS, bindSecretId: false, secretIdNumUses: 0 }));
30
+ expect(bao.seen.map(({ method }) => method)).toEqual(['POST', 'POST']);
31
+ const [first, second] = bao.seen;
32
+ if (first === undefined || second === undefined) throw new Error('expected both SDK writes');
33
+ expect(first.path).toBe('/v1/auth/approle/role/host-cert');
34
+ expect(first.headers.get('x-vault-request')).toBe('true');
35
+ expect(first.headers.get('x-vault-namespace')).toBe('fixture');
36
+ expect(JSON.parse(first.body)).toEqual({
37
+ token_policies: ['host-cert'],
38
+ token_ttl: 900,
39
+ token_max_ttl: 3600,
40
+ secret_id_ttl: 7776000,
41
+ });
42
+ expect(JSON.parse(second.body)).toEqual({
43
+ ...JSON.parse(first.body),
44
+ bind_secret_id: false,
45
+ secret_id_num_uses: 0,
46
+ });
47
+ },
48
+ );
49
+ });
50
+
51
+ for (const [status, tag] of [
52
+ [401, 'Unauthorized'],
53
+ [403, 'Forbidden'],
54
+ [500, 'InternalServerError'],
55
+ ] as const) {
56
+ test(`metadata SDK ${status} propagates for every verb and collision reads without retry`, async () => {
57
+ await withFake(
58
+ () => ({ status, json: { errors: ['refused'] } }),
59
+ async (bao) => {
60
+ const env = { BAO_ADDR: bao.address };
61
+ for (const op of [
62
+ Effect.asVoid(readAuthRole('host-cert')),
63
+ Effect.asVoid(writeAuthRole(PROPS)),
64
+ Effect.asVoid(deleteAuthRole('host-cert')),
65
+ Effect.asVoid(judgeAuthRoleRename({ name: 'old' }, { name: 'new' }, undefined)),
66
+ ])
67
+ expect(await runFailure(env, op)).toMatchObject({ _tag: tag });
68
+ expect(bao.seen.map(({ method }) => method)).toEqual(['GET', 'POST', 'DELETE', 'GET']);
69
+ },
70
+ );
71
+ });
72
+ }
73
+
74
+ test('typed missing read is absence and missing delete is success', async () => {
75
+ await withFake(
76
+ () => ({ status: 404, json: { errors: [] } }),
77
+ async (bao) => {
78
+ const env = { BAO_ADDR: bao.address };
79
+ expect(await run(env, readAuthRole('gone'))).toBeUndefined();
80
+ await run(env, deleteAuthRole('gone'));
81
+ expect(bao.seen).toHaveLength(2);
82
+ },
83
+ );
84
+ });
85
+
86
+ test('a malformed 200 never supplies role defaults or becomes absence', async () => {
87
+ for (const data of [
88
+ null,
89
+ {},
90
+ { ...DATA, token_policies: 'host-cert' },
91
+ { ...DATA, bind_secret_id: 'true' },
92
+ { ...DATA, token_ttl: -1 },
93
+ ]) {
94
+ await withFake(
95
+ () => ({ status: 200, json: { data } }),
96
+ async (bao) => {
97
+ expect(
98
+ String(await runFailure({ BAO_ADDR: bao.address }, readAuthRole('host-cert'))),
99
+ ).toContain('incomplete role metadata');
100
+ },
101
+ );
102
+ }
103
+ });
104
+
105
+ test('unmanaged nullable vendor fields do not prevent a valid metadata read', async () => {
106
+ await withFake(
107
+ () => ({ status: 200, json: { data: { ...DATA, secret_id_bound_cidrs: null } } }),
108
+ async (bao) => {
109
+ expect(await run({ BAO_ADDR: bao.address }, readAuthRole('host-cert'))).toMatchObject(DATA);
110
+ },
111
+ );
112
+ });
113
+
114
+ test('invalid TTL conversion refuses before any SDK exchange', async () => {
115
+ await withFake(
116
+ () => ({ status: 204 }),
117
+ async (bao) => {
118
+ for (const tokenTtl of ['invalid', '999999999999999999999999999h']) {
119
+ expect(
120
+ String(
121
+ await runFailure({ BAO_ADDR: bao.address }, writeAuthRole({ ...PROPS, tokenTtl })),
122
+ ),
123
+ ).toContain('invalid role TTL');
124
+ }
125
+ expect(bao.seen).toEqual([]);
126
+ },
127
+ );
128
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * AppRole METADATA through the named SDK operations (OpenBao 2.6.2 generated schema).
3
+ * ★ pathRoleRead returns nil for absence; logical.RespondErrorCommon turns that read into
4
+ * 404. pathRoleDelete returns nil for an already absent role (204). Reasoned from v2.6.2
5
+ * builtin/credential/approle/path_role.go:1758-1904 and sdk/logical/response_util.go:21-26.
6
+ * ⛔ No role-id, secret-id or login request belongs in this configuration lifecycle.
7
+ */
8
+ import {
9
+ type AppRoleReadRoleResponse,
10
+ type AppRoleWriteRoleRequest,
11
+ appRoleDeleteRole,
12
+ appRoleReadRole,
13
+ appRoleWriteRole,
14
+ } from '@distilled.cloud/openbao/approle';
15
+ import * as Effect from 'effect/Effect';
16
+ import { type BaoAuthRoleProps, readPath } from './auth-role-form.ts';
17
+ import { BaoError } from './bao-status.ts';
18
+ import { runBao, runBaoRead, runBaoWrite } from './distilled.ts';
19
+ import { parseDuration } from './mount-form.ts';
20
+
21
+ const identity = (name: string) => ({ approle_mount_path: 'approle', role_name: name });
22
+ const seconds = (value: unknown): value is number =>
23
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
24
+
25
+ /**
26
+ * The vendor always returns these managed fields (pathRoleRead + tokenutil.PopulateTokenData).
27
+ * Generated response fields are optional, so a proxy's 200 {} must not supply default values.
28
+ * Ignore unmanaged fields: secret_id_bound_cidrs can legitimately be null in the vendor response.
29
+ */
30
+ const completeRole = (live: AppRoleReadRoleResponse): boolean =>
31
+ live !== null &&
32
+ typeof live === 'object' &&
33
+ typeof live.bind_secret_id === 'boolean' &&
34
+ seconds(live.secret_id_num_uses) &&
35
+ seconds(live.secret_id_ttl) &&
36
+ seconds(live.token_ttl) &&
37
+ seconds(live.token_max_ttl) &&
38
+ Array.isArray(live.token_policies) &&
39
+ live.token_policies.every((policy) => typeof policy === 'string');
40
+
41
+ export const readAuthRole = (name: string) =>
42
+ runBaoRead(appRoleReadRole(identity(name))).pipe(
43
+ Effect.flatMap((live) =>
44
+ completeRole(live)
45
+ ? Effect.succeed(live)
46
+ : Effect.fail(new BaoError(200, 'GET', readPath(name), ['incomplete role metadata'])),
47
+ ),
48
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
49
+ );
50
+
51
+ export const authRoleExists = (name: string) =>
52
+ readAuthRole(name).pipe(Effect.map((live) => live !== undefined));
53
+
54
+ export const authRoleRequest = (props: BaoAuthRoleProps) =>
55
+ Effect.gen(function* () {
56
+ const tokenTtl = parseDuration(props.tokenTtl);
57
+ const tokenMaxTtl = parseDuration(props.tokenMaxTtl);
58
+ const secretIdTtl = parseDuration(props.secretIdTtl);
59
+ if (!seconds(tokenTtl) || !seconds(tokenMaxTtl) || !seconds(secretIdTtl)) {
60
+ return yield* Effect.fail(
61
+ new BaoError(0, 'POST', readPath(props.name), ['invalid role TTL duration']),
62
+ );
63
+ }
64
+ // ★ The former CLI-style body used strings for every k=v. The SDK schema uses seconds,
65
+ // booleans and arrays; omitted knobs stay omitted on update.
66
+ // pathRoleCreateUpdate preserves absent bind_secret_id/secret_id_num_uses on existing roles.
67
+ const request: AppRoleWriteRoleRequest = {
68
+ ...identity(props.name),
69
+ token_policies: [...props.tokenPolicies],
70
+ token_ttl: tokenTtl,
71
+ token_max_ttl: tokenMaxTtl,
72
+ secret_id_ttl: secretIdTtl,
73
+ ...(props.bindSecretId === undefined ? {} : { bind_secret_id: props.bindSecretId }),
74
+ ...(props.secretIdNumUses === undefined ? {} : { secret_id_num_uses: props.secretIdNumUses }),
75
+ };
76
+ return request;
77
+ });
78
+
79
+ /**
80
+ * ★ A FULL-REPLACE WRITE, like the policy write: every managed field travels in the request
81
+ * every time (only genuinely omitted knobs stay omitted), so a transport failure is safe to
82
+ * retry through `runBaoWrite` — the replay sends the identical desired state.
83
+ */
84
+ export const writeAuthRole = (props: BaoAuthRoleProps) =>
85
+ authRoleRequest(props).pipe(Effect.flatMap((request) => runBaoWrite(appRoleWriteRole(request))));
86
+
87
+ /** Other typed SDK failures, including denied deletes, propagate unchanged. */
88
+ export const deleteAuthRole = (name: string) =>
89
+ runBao(appRoleDeleteRole(identity(name))).pipe(Effect.catchTag('NotFound', () => Effect.void));
@@ -23,10 +23,10 @@ import {
23
23
  attributesOf,
24
24
  matches,
25
25
  readPath,
26
- writeBody,
27
26
  } from './auth-role-form.ts';
28
- import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
29
- import { foldName, guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
27
+ import { authRoleIdentity, judgeAuthRoleRename } from './auth-role-identity.ts';
28
+ import { deleteAuthRole, readAuthRole, writeAuthRole } from './auth-role-wire.ts';
29
+ import { guardRename } from './rename-identity.ts';
30
30
 
31
31
  export type { BaoAuthRoleAttributes, BaoAuthRoleProps };
32
32
 
@@ -48,13 +48,13 @@ export const BaoAuthRole = Resource<BaoAuthRole>('Bao.AuthRole', {
48
48
  * :1537), so `Host` → `host` is the SAME role: a `replace` there would write it, then — under
49
49
  * `destroy` — delete the old generation, which is that same role.
50
50
  */
51
- const IDENTITY = nameIdentity<BaoAuthRoleAttributes>('Bao.AuthRole', readPath, foldName);
51
+ const IDENTITY = authRoleIdentity;
52
52
 
53
53
  const readRole = (props: BaoAuthRoleProps) =>
54
54
  Effect.gen(function* () {
55
- const live = yield* baoRead(readPath(props.name));
55
+ const live = yield* readAuthRole(props.name);
56
56
  if (live === undefined) return undefined;
57
- return attributesOf(props, live);
57
+ return attributesOf(props, { ...live });
58
58
  });
59
59
 
60
60
  export const BaoAuthRoleProvider = () =>
@@ -92,7 +92,7 @@ export const BaoAuthRoleProvider = () =>
92
92
  * generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
93
93
  * opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
94
94
  */
95
- const move = yield* judgeRename(IDENTITY, olds, news, output);
95
+ const move = yield* judgeAuthRoleRename(olds, news, output);
96
96
  // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
97
97
  if (output === undefined) return undefined;
98
98
  if (move !== undefined) return { action: 'replace' } as const;
@@ -111,7 +111,7 @@ export const BaoAuthRoleProvider = () =>
111
111
  if (live !== undefined)
112
112
  yield* refuseTakeover({ fqn, instanceId, output }, `Bao.AuthRole ${path}`);
113
113
  if (live === undefined || !matches(live, news)) {
114
- yield* baoWrite('PUT', readPath(news.name), writeBody(news));
114
+ yield* writeAuthRole(news);
115
115
  }
116
116
  const after = yield* readRole(news);
117
117
  if (after === undefined) {
@@ -136,7 +136,7 @@ export const BaoAuthRoleProvider = () =>
136
136
  * because every host logging in with the role is locked out at the delete.
137
137
  */
138
138
  delete: Effect.fn(function* ({ output }) {
139
- yield* baoDelete(readPath(output.name));
139
+ yield* deleteAuthRole(output.name);
140
140
  return undefined;
141
141
  }),
142
142
  }),
@@ -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
+ });