@homeflare/distilled-openbao 0.2.0 → 0.2.2

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/README.md +28 -3
  2. package/dist/credentials.d.ts +3 -2
  3. package/dist/credentials.d.ts.map +1 -1
  4. package/dist/credentials.js +5 -3
  5. package/dist/credentials.js.map +1 -1
  6. package/dist/errors.d.ts +2 -2
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/protocol-mount-tables.d.ts +10 -0
  9. package/dist/protocol-mount-tables.d.ts.map +1 -0
  10. package/dist/protocol-mount-tables.js +39 -0
  11. package/dist/protocol-mount-tables.js.map +1 -0
  12. package/dist/protocol-mount-tables.test.d.ts +2 -0
  13. package/dist/protocol-mount-tables.test.d.ts.map +1 -0
  14. package/dist/protocol-mount-tables.test.js +164 -0
  15. package/dist/protocol-mount-tables.test.js.map +1 -0
  16. package/dist/protocol-requests.test.d.ts +2 -0
  17. package/dist/protocol-requests.test.d.ts.map +1 -0
  18. package/dist/protocol-requests.test.js +32 -0
  19. package/dist/protocol-requests.test.js.map +1 -0
  20. package/dist/protocol.d.ts.map +1 -1
  21. package/dist/protocol.js +11 -5
  22. package/dist/protocol.js.map +1 -1
  23. package/dist/retry.d.ts +4 -5
  24. package/dist/retry.d.ts.map +1 -1
  25. package/dist/retry.js +4 -5
  26. package/dist/retry.js.map +1 -1
  27. package/dist/services/auth.d.ts +46 -1
  28. package/dist/services/auth.d.ts.map +1 -1
  29. package/dist/services/auth.js +39 -1
  30. package/dist/services/auth.js.map +1 -1
  31. package/dist/services/mounts.d.ts +46 -1
  32. package/dist/services/mounts.d.ts.map +1 -1
  33. package/dist/services/mounts.js +39 -1
  34. package/dist/services/mounts.js.map +1 -1
  35. package/dist/traits.d.ts +3 -8
  36. package/dist/traits.d.ts.map +1 -1
  37. package/dist/traits.js +5 -1
  38. package/dist/traits.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/credentials.ts +9 -5
  41. package/src/errors.ts +4 -2
  42. package/src/protocol-mount-tables.test.ts +190 -0
  43. package/src/protocol-mount-tables.ts +60 -0
  44. package/src/protocol-requests.test.ts +66 -0
  45. package/src/protocol.ts +11 -5
  46. package/src/retry.ts +4 -5
  47. package/src/services/auth.ts +96 -2
  48. package/src/services/mounts.ts +96 -2
  49. package/src/traits.ts +9 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The auth/mount inventories require a data map (OpenBao v2.6.2
3
+ * vault/logical_system.go:930-959,2108-2134; api/v2 sys_{auth,mounts}.go).
4
+ * A missing envelope must never masquerade as an empty table and authorize
5
+ * a consumer's create. Other logical, health and login shapes are separate.
6
+ */
7
+ import * as API from "@distilled.cloud/core/api";
8
+ import { getAnn } from "@distilled.cloud/core/protocol-http";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Layer from "effect/Layer";
11
+ import * as Schema from "effect/Schema";
12
+ import { OpenBaoParseError } from "./errors.ts";
13
+ import { mountTableSymbol } from "./traits.ts";
14
+
15
+ const invalidTable = () =>
16
+ new OpenBaoParseError({
17
+ body: undefined,
18
+ cause: "Response does not match the declared OpenBao mount table schema",
19
+ });
20
+ const record = (body: unknown): body is Record<string, unknown> =>
21
+ typeof body === "object" && body !== null && !Array.isArray(body);
22
+
23
+ export const withMountTables = (
24
+ base: Layer.Layer<API.Protocol>,
25
+ ): Layer.Layer<API.Protocol> =>
26
+ Layer.effect(
27
+ API.Protocol,
28
+ Effect.gen(function* () {
29
+ const protocol = yield* API.Protocol;
30
+ return API.Protocol.of({
31
+ encode: protocol.encode,
32
+ decode: (args) => {
33
+ if (
34
+ !getAnn(args.outputAst, mountTableSymbol) ||
35
+ args.response.status >= 400 ||
36
+ args.config.output === undefined
37
+ )
38
+ return protocol.decode(args);
39
+ // These schemas use wire names and contain no sensitive transforms.
40
+ // Validate the original envelope before core's empty-body fallback;
41
+ // retain unknown metadata after validation for forward compatibility.
42
+ // Neither body values nor schema diagnostics may enter parse errors.
43
+ const output = args.config.output;
44
+ return args.response.json.pipe(
45
+ Effect.mapError(invalidTable),
46
+ Effect.flatMap((envelope) =>
47
+ record(envelope) && record(envelope.data)
48
+ ? Schema.decodeUnknownEffect(Schema.toType(output))(
49
+ envelope.data,
50
+ ).pipe(
51
+ Effect.as(envelope.data),
52
+ Effect.mapError(invalidTable),
53
+ )
54
+ : Effect.fail(invalidTable()),
55
+ ),
56
+ ) as Effect.Effect<unknown>;
57
+ },
58
+ });
59
+ }),
60
+ ).pipe(Layer.provide(base));
@@ -0,0 +1,66 @@
1
+ import { expect, test } from "bun:test";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as HttpClient from "effect/unstable/http/HttpClient";
5
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
6
+ import { credentials } from "./credentials.ts";
7
+ import * as Retry from "./retry.ts";
8
+ import { policiesDeleteAclPolicy } from "./services/policies.ts";
9
+
10
+ for (const token of [undefined, "", "test-token"]) {
11
+ test(`agent request header is present; ${token ? "nonempty" : "absent or empty"} token has correct header semantics`, async () => {
12
+ const client = HttpClient.make((request) => {
13
+ expect(request.headers["x-vault-request"]).toBe("true");
14
+ if (token) expect(request.headers["x-vault-token"]).toBe(token);
15
+ else expect(request.headers["x-vault-token"]).toBeUndefined();
16
+ expect(request.headers["x-vault-namespace"]).toBeUndefined();
17
+ return Effect.succeed(
18
+ HttpClientResponse.fromWeb(
19
+ request,
20
+ new Response(null, { status: 204 }),
21
+ ),
22
+ );
23
+ });
24
+ await Effect.runPromise(
25
+ policiesDeleteAclPolicy({ name: "test" }).pipe(
26
+ Retry.none,
27
+ Effect.provide(
28
+ Layer.mergeAll(
29
+ Layer.succeed(HttpClient.HttpClient, client),
30
+ credentials({ addr: "http://fake.invalid", token }),
31
+ ),
32
+ ),
33
+ ),
34
+ );
35
+ });
36
+ }
37
+
38
+ test("every status mapped by the protocol is represented in the operation error union", async () => {
39
+ // These catchTag calls must compile: core DEFAULT_ERRORS excludes both tags,
40
+ // but OpenBaoProtocol's default HTTP_STATUS_MAP emits them for every operation.
41
+ for (const status of [403, 404]) {
42
+ const client = HttpClient.make((request) =>
43
+ Effect.succeed(
44
+ HttpClientResponse.fromWeb(
45
+ request,
46
+ Response.json({ errors: [] }, { status }),
47
+ ),
48
+ ),
49
+ );
50
+ const tag = await Effect.runPromise(
51
+ policiesDeleteAclPolicy({ name: "test" }).pipe(
52
+ Retry.none,
53
+ Effect.as("success"),
54
+ Effect.catchTag("Forbidden", () => Effect.succeed("Forbidden")),
55
+ Effect.catchTag("NotFound", () => Effect.succeed("NotFound")),
56
+ Effect.provide(
57
+ Layer.mergeAll(
58
+ Layer.succeed(HttpClient.HttpClient, client),
59
+ credentials({ addr: "http://fake.invalid", token: "test-token" }),
60
+ ),
61
+ ),
62
+ ),
63
+ );
64
+ expect(tag).toBe(status === 403 ? "Forbidden" : "NotFound");
65
+ }
66
+ });
package/src/protocol.ts CHANGED
@@ -31,9 +31,11 @@
31
31
  * match on status, or on message when OpenBao actually supplies one — see
32
32
  * `patches/`.
33
33
  *
34
- * Authentication is a single header, `X-Vault-Token`, plus an optional
35
- * `X-Vault-Namespace` when the credential carries one — the estate always
36
- * does (`BAO_NAMESPACE=homeflare`). A namespace does not change a mounted
34
+ * Authentication is `X-Vault-Token` when a credential carries a nonempty
35
+ * token; omit it for a local agent that supplies its own identity. An
36
+ * optional `X-Vault-Namespace` selects a namespace; absent means root.
37
+ * `X-Vault-Request: true` admits agent listeners with require_request_header
38
+ * (OpenBao v2.6.2 command/agent.go:544-548). A namespace does not change a mounted
37
39
  * backend's own path shape (see `stacks/distilled-submodules/spec-repos/
38
40
  * openbao/fetch-specs.ts`'s module docs for how that was confirmed), so it
39
41
  * is purely a request header here, never a path segment.
@@ -51,6 +53,7 @@ import {
51
53
  } from "@distilled.cloud/core/protocol-rest";
52
54
  import { Credentials, type Config } from "./credentials.ts";
53
55
  import { UnknownOpenBaoError, type DefaultErrors } from "./errors.ts";
56
+ import { withMountTables } from "./protocol-mount-tables.ts";
54
57
 
55
58
  /**
56
59
  * Error channel shared by every generated OpenBao operation. Generated
@@ -100,7 +103,10 @@ export const OpenBaoProtocol: Layer.Layer<API.Protocol> =
100
103
  }),
101
104
  baseUrl: (creds) => creds.apiBaseUrl,
102
105
  headers: (creds) => ({
103
- "X-Vault-Token": Redacted.value(creds.token),
106
+ ...(creds.token && Redacted.value(creds.token) !== ""
107
+ ? { "X-Vault-Token": Redacted.value(creds.token) }
108
+ : {}),
109
+ "X-Vault-Request": "true",
104
110
  ...(creds.namespace ? { "X-Vault-Namespace": creds.namespace } : {}),
105
111
  Accept: "application/json",
106
112
  }),
@@ -117,4 +123,4 @@ export const OpenBaoProtocol: Layer.Layer<API.Protocol> =
117
123
  message,
118
124
  body,
119
125
  }),
120
- });
126
+ }).pipe(withMountTables);
package/src/retry.ts CHANGED
@@ -10,11 +10,10 @@
10
10
  * `packages/alchemy/src/openbao/bao-http.ts` caps in-flight OpenBao
11
11
  * exchanges at 8 process-wide (`BaoGate`, a `Semaphore`) after a real
12
12
  * outage: unbounded Alchemy fan-out took the dev listener's socket down
13
- * (measured 2026-09-14). That cap is `HttpClient` middleware, not a retry
14
- * policy, and stays exactly where it is — the kit's Effect runtime provides
15
- * the gated `HttpClient.HttpClient` layer underneath whichever protocol
16
- * (hand-rolled or this SDK) issues the request, so it keeps applying
17
- * unchanged to every call this package makes.
13
+ * (measured 2026-09-14). That cap lives inside the kit's call runner, not
14
+ * inside HttpClient. SDK consumers must explicitly share the same gate;
15
+ * merely supplying the HTTP client does not apply it. The kit runner also
16
+ * selects Retry.none so a failed response cannot repeat a sent write.
18
17
  *
19
18
  * @example
20
19
  * ```ts
@@ -83,9 +83,103 @@ export const AuthListEnabledMethodsRequest = /*@__PURE__*/ S.suspend(() =>
83
83
  identifier: "AuthListEnabledMethodsRequest",
84
84
  }) as any as S.Schema<AuthListEnabledMethodsRequest>;
85
85
 
86
- export interface AuthListEnabledMethodsResponse {}
86
+ export type MountOptions = { [key: string]: string | undefined };
87
+ export const MountOptions = /*@__PURE__*/ S.Record(
88
+ S.String,
89
+ S.String,
90
+ ) as any as S.Schema<MountOptions>;
91
+
92
+ export type MountStrings = Array<string>;
93
+ export const MountStrings = /*@__PURE__*/ S.Array(
94
+ S.String,
95
+ ) as any as S.Schema<MountStrings>;
96
+
97
+ export interface MountUserLockoutConfig {
98
+ user_lockout_counter_reset_duration: number;
99
+ user_lockout_threshold: number;
100
+ user_lockout_duration: number;
101
+ user_lockout_disable: boolean;
102
+ }
103
+ export const MountUserLockoutConfig = /*@__PURE__*/ S.suspend(() =>
104
+ S.Struct({
105
+ user_lockout_counter_reset_duration: S.Number,
106
+ user_lockout_threshold: S.Number,
107
+ user_lockout_duration: S.Number,
108
+ user_lockout_disable: S.Boolean,
109
+ }),
110
+ ).annotate({
111
+ identifier: "MountUserLockoutConfig",
112
+ }) as any as S.Schema<MountUserLockoutConfig>;
113
+
114
+ export interface MountConfig {
115
+ default_lease_ttl: number;
116
+ max_lease_ttl: number;
117
+ force_no_cache: boolean;
118
+ audit_non_hmac_request_keys?: MountStrings;
119
+ audit_non_hmac_response_keys?: MountStrings;
120
+ passthrough_request_headers?: MountStrings;
121
+ allowed_response_headers?: MountStrings;
122
+ listing_visibility?: string;
123
+ token_type?: string;
124
+ user_lockout_config?: MountUserLockoutConfig;
125
+ }
126
+ export const MountConfig = /*@__PURE__*/ S.suspend(() =>
127
+ S.Struct({
128
+ default_lease_ttl: S.Number,
129
+ max_lease_ttl: S.Number,
130
+ force_no_cache: S.Boolean,
131
+ audit_non_hmac_request_keys: S.optional(MountStrings),
132
+ audit_non_hmac_response_keys: S.optional(MountStrings),
133
+ passthrough_request_headers: S.optional(MountStrings),
134
+ allowed_response_headers: S.optional(MountStrings),
135
+ listing_visibility: S.optional(S.String),
136
+ token_type: S.optional(S.String),
137
+ user_lockout_config: S.optional(MountUserLockoutConfig),
138
+ }),
139
+ ).annotate({ identifier: "MountConfig" }) as any as S.Schema<MountConfig>;
140
+
141
+ export interface MountInfo {
142
+ type: string;
143
+ description: string;
144
+ accessor: string;
145
+ uuid: string;
146
+ plugin_version: string;
147
+ running_plugin_version: string;
148
+ running_sha256: string;
149
+ local: boolean;
150
+ seal_wrap: boolean;
151
+ external_entropy_access: boolean;
152
+ options: MountOptions | null;
153
+ config: MountConfig;
154
+ deprecation_status?: string;
155
+ }
156
+ export const MountInfo = /*@__PURE__*/ S.suspend(() =>
157
+ S.Struct({
158
+ type: S.String,
159
+ description: S.String,
160
+ accessor: S.String,
161
+ uuid: S.String,
162
+ plugin_version: S.String,
163
+ running_plugin_version: S.String,
164
+ running_sha256: S.String,
165
+ local: S.Boolean,
166
+ seal_wrap: S.Boolean,
167
+ external_entropy_access: S.Boolean,
168
+ options: S.NullOr(MountOptions),
169
+ config: MountConfig,
170
+ deprecation_status: S.optional(S.String),
171
+ }),
172
+ ).annotate({ identifier: "MountInfo" }) as any as S.Schema<MountInfo>;
173
+
174
+ export type MountTable = { [key: string]: MountInfo | undefined };
175
+ export const MountTable = /*@__PURE__*/ S.Record(
176
+ S.String,
177
+ MountInfo,
178
+ ) as any as S.Schema<MountTable>;
179
+
180
+ export type AuthListEnabledMethodsResponse = MountTable;
87
181
  export const AuthListEnabledMethodsResponse = /*@__PURE__*/ S.suspend(() =>
88
- S.Struct({}),
182
+ MountTable.pipe(T.MountTable()),
89
183
  ).annotate({
90
184
  identifier: "AuthListEnabledMethodsResponse",
91
185
  }) as any as S.Schema<AuthListEnabledMethodsResponse>;
@@ -83,9 +83,103 @@ export const MountsListSecretsEnginesRequest = /*@__PURE__*/ S.suspend(() =>
83
83
  identifier: "MountsListSecretsEnginesRequest",
84
84
  }) as any as S.Schema<MountsListSecretsEnginesRequest>;
85
85
 
86
- export interface MountsListSecretsEnginesResponse {}
86
+ export type MountOptions = { [key: string]: string | undefined };
87
+ export const MountOptions = /*@__PURE__*/ S.Record(
88
+ S.String,
89
+ S.String,
90
+ ) as any as S.Schema<MountOptions>;
91
+
92
+ export type MountStrings = Array<string>;
93
+ export const MountStrings = /*@__PURE__*/ S.Array(
94
+ S.String,
95
+ ) as any as S.Schema<MountStrings>;
96
+
97
+ export interface MountUserLockoutConfig {
98
+ user_lockout_counter_reset_duration: number;
99
+ user_lockout_threshold: number;
100
+ user_lockout_duration: number;
101
+ user_lockout_disable: boolean;
102
+ }
103
+ export const MountUserLockoutConfig = /*@__PURE__*/ S.suspend(() =>
104
+ S.Struct({
105
+ user_lockout_counter_reset_duration: S.Number,
106
+ user_lockout_threshold: S.Number,
107
+ user_lockout_duration: S.Number,
108
+ user_lockout_disable: S.Boolean,
109
+ }),
110
+ ).annotate({
111
+ identifier: "MountUserLockoutConfig",
112
+ }) as any as S.Schema<MountUserLockoutConfig>;
113
+
114
+ export interface MountConfig {
115
+ default_lease_ttl: number;
116
+ max_lease_ttl: number;
117
+ force_no_cache: boolean;
118
+ audit_non_hmac_request_keys?: MountStrings;
119
+ audit_non_hmac_response_keys?: MountStrings;
120
+ passthrough_request_headers?: MountStrings;
121
+ allowed_response_headers?: MountStrings;
122
+ listing_visibility?: string;
123
+ token_type?: string;
124
+ user_lockout_config?: MountUserLockoutConfig;
125
+ }
126
+ export const MountConfig = /*@__PURE__*/ S.suspend(() =>
127
+ S.Struct({
128
+ default_lease_ttl: S.Number,
129
+ max_lease_ttl: S.Number,
130
+ force_no_cache: S.Boolean,
131
+ audit_non_hmac_request_keys: S.optional(MountStrings),
132
+ audit_non_hmac_response_keys: S.optional(MountStrings),
133
+ passthrough_request_headers: S.optional(MountStrings),
134
+ allowed_response_headers: S.optional(MountStrings),
135
+ listing_visibility: S.optional(S.String),
136
+ token_type: S.optional(S.String),
137
+ user_lockout_config: S.optional(MountUserLockoutConfig),
138
+ }),
139
+ ).annotate({ identifier: "MountConfig" }) as any as S.Schema<MountConfig>;
140
+
141
+ export interface MountInfo {
142
+ type: string;
143
+ description: string;
144
+ accessor: string;
145
+ uuid: string;
146
+ plugin_version: string;
147
+ running_plugin_version: string;
148
+ running_sha256: string;
149
+ local: boolean;
150
+ seal_wrap: boolean;
151
+ external_entropy_access: boolean;
152
+ options: MountOptions | null;
153
+ config: MountConfig;
154
+ deprecation_status?: string;
155
+ }
156
+ export const MountInfo = /*@__PURE__*/ S.suspend(() =>
157
+ S.Struct({
158
+ type: S.String,
159
+ description: S.String,
160
+ accessor: S.String,
161
+ uuid: S.String,
162
+ plugin_version: S.String,
163
+ running_plugin_version: S.String,
164
+ running_sha256: S.String,
165
+ local: S.Boolean,
166
+ seal_wrap: S.Boolean,
167
+ external_entropy_access: S.Boolean,
168
+ options: S.NullOr(MountOptions),
169
+ config: MountConfig,
170
+ deprecation_status: S.optional(S.String),
171
+ }),
172
+ ).annotate({ identifier: "MountInfo" }) as any as S.Schema<MountInfo>;
173
+
174
+ export type MountTable = { [key: string]: MountInfo | undefined };
175
+ export const MountTable = /*@__PURE__*/ S.Record(
176
+ S.String,
177
+ MountInfo,
178
+ ) as any as S.Schema<MountTable>;
179
+
180
+ export type MountsListSecretsEnginesResponse = MountTable;
87
181
  export const MountsListSecretsEnginesResponse = /*@__PURE__*/ S.suspend(() =>
88
- S.Struct({}),
182
+ MountTable.pipe(T.MountTable()),
89
183
  ).annotate({
90
184
  identifier: "MountsListSecretsEnginesResponse",
91
185
  }) as any as S.Schema<MountsListSecretsEnginesResponse>;
package/src/traits.ts CHANGED
@@ -4,8 +4,16 @@
4
4
  * Re-exports the generic protocol traits from core so generated operations
5
5
  * import everything from one place. OpenBao speaks plain token-REST JSON
6
6
  * with a `data`-enveloped success body (unwrapped in `protocol.ts`), so
7
- * there are no provider-specific traits.
7
+ * MountTable marks the two inventory responses whose data is a path-keyed map.
8
8
  */
9
+ import { makeAnnotation } from "@distilled.cloud/core/trait";
10
+
11
+ export const mountTableSymbol = Symbol.for(
12
+ "@distilled.cloud/openbao/mount-table",
13
+ );
14
+ /** A typed dynamic map inside a required logical-response data envelope. */
15
+ export const MountTable = () => makeAnnotation(mountTableSymbol, true);
16
+
9
17
  export {
10
18
  Body,
11
19
  Header,