@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
@@ -54,14 +54,17 @@ export interface NodeNetworkAttributes {
54
54
  * `{"errors":{"iface":"interface does not exist"},"data":null,"message":"Parameter verification
55
55
  * failed.\n"}` at HTTP 400 — a THIRD shape, different from every other migrated family's
56
56
  * measured 500. `@distilled.cloud/proxmox` types this as a genuine typed error,
57
- * `ParameterVerificationFailed` (`errors.ts`), carrying the per-field `errors` object rather
58
- * than collapsing it to an opaque `BadRequest` — PVE's OWN 400 is never one shape (it is
59
- * whatever the endpoint's parameter verification rejected), so `errors.ts`'s own header spells
60
- * out why this ONE typed error is global rather than per-operation.
61
- * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason, a
62
- * permission gap, a network blip, or ANY OTHER `ParameterVerificationFailed` reason is never
63
- * read as "this interface is not there" — `diff` calls this directly and propagates every one
64
- * of them loudly.
57
+ * `NetworkInterfaceNotFound` (`nodes.ts`, generated from `patches/nodes/network-errors.json`),
58
+ * attached ONLY to `getNodeNetwork`'s error union and matched by the SDK's own
59
+ * `errorEnvelope`/`matchTypedError` against the EXACT sole-field body `errors.iface ===
60
+ * "interface does not exist"` — the precision the code here used to check by hand
61
+ * (`error.errors['iface'] === '…'`) is now the SDK's own matcher contract, not this file's.
62
+ * Any 400 that fails that exact match (a different field, a different message, more than one
63
+ * field) decodes as the generic `ParameterVerificationFailed` instead, never this tag.
64
+ * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason
65
+ * (`ParameterVerificationFailed`), a permission gap, a network blip, or any other failure is
66
+ * never read as "this interface is not there" — `diff` calls this directly and propagates
67
+ * every one of them loudly.
65
68
  */
66
69
  export declare const readInterfaceOrFail: (props: NodeNetworkProps) => Effect.Effect<typeof UNREADABLE | NodeNetworkAttributes | undefined, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | import("@distilled.cloud/caddy").Conflict | Error | import("@distilled.cloud/caddy").Forbidden | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/caddy").Locked | import("@distilled.cloud/caddy").NotFound | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError | import("@distilled.cloud/caddy").UnprocessableEntity, import("effect/unstable/http/HttpClient").HttpClient>;
67
70
  /**
@@ -76,8 +79,9 @@ export declare const readInterfaceOrFail: (props: NodeNetworkProps) => Effect.Ef
76
79
  * interrupted-create recovery, and Apply.ts's delete recovery, all call `read` with
77
80
  * `output: undefined` and NO catch of their own (`Plan.ts` v2.0.0-beta.79 ~line 1308) — and
78
81
  * Plan.ts fails the WHOLE PLAN, every other resource included, if that call throws. A malformed
79
- * `iface` in ONE brand-new declaration (a DIFFERENT `ParameterVerificationFailed` than the
80
- * absence one) would abort `bun run plan` for the entire stack under the precise-only design,
82
+ * `iface` in ONE brand-new declaration (a `ParameterVerificationFailed`, DIFFERENT from the
83
+ * `NetworkInterfaceNotFound` absence tag) would abort `bun run plan` for the entire stack under
84
+ * the precise-only design,
81
85
  * where every other migrated family instead defers that same malformed declaration to a scoped,
82
86
  * loud refusal at apply. This restores that same fold for those three call sites.
83
87
  */
@@ -1 +1 @@
1
- {"version":3,"file":"node-network-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/node-network-wire.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EACL,KAAK,gBAAgB,EAOtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,WAAW,GACX,YAAY,GACZ,SAAS,GACT,OAAO,GACP,MAAM,GACN,QAAQ,GACR,KAAK,GACL,QAAQ,GACR,SAAS,GACT,MAAM,GACN,MAAM,CAAC;AAEX;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AA4DD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,mBAAmB,UAAW,gBAAgB,ylCAmBxD,CAAC;AAEJ;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,UAAW,gBAAgB,sIACmB,CAAC;AAEzE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,qBAAqB,GAAG,UAAU,GAAG,SAAS,sCAC3C,CAAC;AAEzC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,eAAgB,qBAAqB,SAAS,gBAAgB,YAcnB,CAAC"}
1
+ {"version":3,"file":"node-network-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/node-network-wire.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EACL,KAAK,gBAAgB,EAOtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,WAAW,GACX,YAAY,GACZ,SAAS,GACT,OAAO,GACP,MAAM,GACN,QAAQ,GACR,KAAK,GACL,QAAQ,GACR,SAAS,GACT,MAAM,GACN,MAAM,CAAC;AAEX;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AA4DD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB,UAAW,gBAAgB,ylCAaxD,CAAC;AAEJ;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,aAAa,UAAW,gBAAgB,sIACmB,CAAC;AAEzE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,qBAAqB,GAAG,UAAU,GAAG,SAAS,sCAC3C,CAAC;AAEzC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,eAAgB,qBAAqB,SAAS,gBAAgB,YAcnB,CAAC"}
@@ -144,9 +144,11 @@ then tidiness.
144
144
  - 42 test files on `node:test` (S43);
145
145
  - `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
146
146
 
147
- No distilled SDK exists. OpenBao serves an OpenAPI document, so a generated SDK in
148
- distilled's shape is the S23 path. **Decision:** maintainer, on whether the kit authors
149
- a distilled package.
147
+ The generated SDK ships as `@homeflare/distilled-openbao` (interim alias for
148
+ `@distilled.cloud/openbao`). `Bao.Policy` and `Bao.AuthRole` use it for CRUD and rename collision
149
+ reads, sharing the existing eight-request gate, timeout and agent transport. Other
150
+ Bao families still use the raw client. Auth/login envelope decoding and house plugin
151
+ schemas remain separate SDK work before those families can migrate.
150
152
 
151
153
  ## Remaining family findings
152
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.37.5",
3
+ "version": "0.37.6",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -122,9 +122,10 @@
122
122
  "@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
123
123
  "@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
124
124
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
125
+ "@distilled.cloud/openbao": "npm:@homeflare/distilled-openbao@0.2.2",
125
126
  "@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.2.0",
126
127
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
127
- "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.1",
128
+ "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.2",
128
129
  "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
129
130
  "@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.2.0"
130
131
  },
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Bao.AuthRole's secret_id TTL at the 90-day length the per-host AppRoles use.
3
3
  *
4
- * ★ `2160h` IS WHAT GOES ON THE WIRE, AND OPENBAO ACCEPTS IT. MEASURED 2026-09-21 with a Go probe of
4
+ * ★ OPENBAO ACCEPTS `2160h`. MEASURED 2026-09-21 with a Go probe of
5
5
  * sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
6
6
  * TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
7
7
  * read hands back that integer, which attributesOf renders as `90d` — the same role.
8
+ * The SDK now sends the equivalent seconds directly instead of the former CLI duration string.
8
9
  * (What the diff calls a rename is pinned in rename-families.test.ts, `foldName`.)
9
10
  */
10
- import assert from 'node:assert/strict';
11
- import { describe, it } from 'node:test';
12
- import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
11
+ import { describe, expect, it } from 'bun:test';
12
+ import * as Effect from 'effect/Effect';
13
+ import { type BaoAuthRoleProps, attributesOf, matches } from './auth-role-form.ts';
14
+ import { authRoleRequest } from './auth-role-wire.ts';
13
15
 
14
16
  const PROPS: BaoAuthRoleProps = {
15
17
  bindSecretId: true,
@@ -22,8 +24,8 @@ const PROPS: BaoAuthRoleProps = {
22
24
  };
23
25
 
24
26
  describe('auth role secret_id TTL', () => {
25
- it('writes 2160h as given', () => {
26
- assert.equal(writeBody(PROPS)['secret_id_ttl'], '2160h');
27
+ it('writes 2160h as the equivalent SDK seconds', async () => {
28
+ expect((await Effect.runPromise(authRoleRequest(PROPS))).secret_id_ttl).toBe(7776000);
27
29
  });
28
30
 
29
31
  it('reads 7776000 seconds back as the same role', () => {
@@ -36,8 +38,8 @@ describe('auth role secret_id TTL', () => {
36
38
  token_ttl: 900,
37
39
  };
38
40
  const attributes = attributesOf(PROPS, live);
39
- assert.equal(attributes.secretIdTtl, '90d');
40
- assert.equal(matches(attributes, PROPS), true);
41
- assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
41
+ expect(attributes.secretIdTtl).toBe('90d');
42
+ expect(matches(attributes, PROPS)).toBe(true);
43
+ expect(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS)).toBe(false);
42
44
  });
43
45
  });
@@ -70,25 +70,6 @@ export const attributesOf = (
70
70
 
71
71
  export const readPath = (name: string) => `auth/approle/role/${name}`;
72
72
 
73
- /**
74
- * The body for `PUT auth/approle/role/<name>`.
75
- *
76
- * ★ ALL STRINGS, EXACTLY THE `k=v` PAIRS `bao write` USED TO SEND — see `baoWrite` in bao-http.ts.
77
- */
78
- export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
79
- const body: Record<string, string> = {
80
- token_policies: props.tokenPolicies.join(','),
81
- token_ttl: props.tokenTtl,
82
- token_max_ttl: props.tokenMaxTtl,
83
- secret_id_ttl: props.secretIdTtl,
84
- };
85
- if (props.bindSecretId !== undefined) body['bind_secret_id'] = String(props.bindSecretId);
86
- if (props.secretIdNumUses !== undefined) {
87
- body['secret_id_num_uses'] = String(props.secretIdNumUses);
88
- }
89
- return body;
90
- };
91
-
92
73
  const samePolicies = (want: readonly string[], have: readonly string[]) =>
93
74
  want.length === have.length && want.every((policy, index) => policy === have[index]);
94
75
 
@@ -0,0 +1,24 @@
1
+ import { readPath } from './auth-role-form.ts';
2
+ import { authRoleExists } from './auth-role-wire.ts';
3
+ import { declaredString, judgeMoveWith } from './rename.ts';
4
+ import { foldName, nameIdentity } from './rename-identity.ts';
5
+
6
+ export const authRoleIdentity = nameIdentity<{ readonly name: string }>(
7
+ 'Bao.AuthRole',
8
+ readPath,
9
+ foldName,
10
+ );
11
+
12
+ /** All occupied-name reads share the SDK's typed absence and refusal behavior. */
13
+ export const judgeAuthRoleRename = (
14
+ olds: unknown,
15
+ news: unknown,
16
+ output: { readonly name: string } | undefined,
17
+ ) =>
18
+ judgeMoveWith(
19
+ 'Bao.AuthRole',
20
+ output === undefined ? declaredString(olds, 'name') : output.name,
21
+ declaredString(news, 'name'),
22
+ authRoleExists,
23
+ foldName,
24
+ );
@@ -0,0 +1,155 @@
1
+ /** AppRole metadata through the real SDK and Alchemy's upstream test harness; no real vault. */
2
+ import { afterAll, beforeEach, expect } from 'bun:test';
3
+ import { adopt } from 'alchemy/AdoptPolicy';
4
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
5
+ import * as Test from 'alchemy/Test/Bun';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
9
+ import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
10
+ import { BaoEnv } from './bao-http.ts';
11
+ import { type Reply, fakeBao } from './fake-bao.ts';
12
+ import { wireRoles } from './fake-engines-roles.ts';
13
+ import { writesOf } from './fake-stack.ts';
14
+
15
+ const store = wireRoles();
16
+ let refused: Reply | undefined;
17
+ const bao = fakeBao((seen) =>
18
+ seen.method === 'GET' && refused !== undefined ? refused : store(seen),
19
+ );
20
+ afterAll(() => bao.stop());
21
+ beforeEach(() => {
22
+ store.live.clear();
23
+ bao.seen.length = 0;
24
+ refused = undefined;
25
+ });
26
+ const { test } = Test.make({
27
+ adopt: true,
28
+ dev: false,
29
+ sidecar: false,
30
+ stage: 'test',
31
+ providers: Layer.mergeAll(
32
+ BaoAuthRoleProvider(),
33
+ FetchHttpClient.layer,
34
+ Layer.succeed(BaoEnv, { BAO_ADDR: bao.address }),
35
+ ),
36
+ });
37
+ const props = {
38
+ name: 'host-cert',
39
+ tokenPolicies: ['host-cert'],
40
+ tokenTtl: '15m',
41
+ tokenMaxTtl: '1h',
42
+ secretIdTtl: '2160h',
43
+ };
44
+ const path = 'auth/approle/role/host-cert';
45
+ const existing = () =>
46
+ store.live.set(path, {
47
+ bind_secret_id: false,
48
+ secret_id_num_uses: 7,
49
+ secret_id_ttl: 7776000,
50
+ token_max_ttl: 3600,
51
+ token_policies: ['host-cert'],
52
+ token_ttl: 900,
53
+ token_period: 1800,
54
+ secret_id_bound_cidrs: ['192.0.2.0/24'],
55
+ });
56
+ const role = (tokenTtl = '15m') => BaoAuthRole('role', { ...props, tokenTtl });
57
+ const actions = (plan: { resources: Record<string, { action: string }> }) =>
58
+ Object.values(plan.resources).map((row) => row.action);
59
+
60
+ test.provider('existing metadata adopts without writes, then plans and deploys noop', (scratch) =>
61
+ Effect.gen(function* () {
62
+ existing();
63
+ yield* scratch.deploy(role());
64
+ expect(writesOf(bao.seen)).toEqual([]);
65
+ expect(actions(yield* scratch.plan(role()))).toEqual(['noop']);
66
+ yield* scratch.deploy(role());
67
+ expect(writesOf(bao.seen)).toEqual([]);
68
+ }),
69
+ );
70
+
71
+ test.provider('drift writes one SDK POST and preserves omitted and unmanaged settings', (scratch) =>
72
+ Effect.gen(function* () {
73
+ existing();
74
+ yield* scratch.deploy(role());
75
+ yield* scratch.deploy(role('30m'));
76
+ expect(writesOf(bao.seen)).toEqual(['POST /v1/auth/approle/role/host-cert']);
77
+ expect(store.live.get(path)).toMatchObject({
78
+ bind_secret_id: false,
79
+ secret_id_num_uses: 7,
80
+ token_period: 1800,
81
+ token_ttl: 1800,
82
+ secret_id_bound_cidrs: ['192.0.2.0/24'],
83
+ });
84
+ expect(actions(yield* scratch.plan(role('30m')))).toEqual(['noop']);
85
+ }),
86
+ );
87
+
88
+ test.provider(
89
+ 'typed absence creates metadata with server defaults then settles to noop',
90
+ (scratch) =>
91
+ Effect.gen(function* () {
92
+ yield* scratch.deploy(role());
93
+ expect(writesOf(bao.seen)).toEqual(['POST /v1/auth/approle/role/host-cert']);
94
+ expect(store.live.get(path)).toMatchObject({ bind_secret_id: true, secret_id_num_uses: 0 });
95
+ expect(actions(yield* scratch.plan(role()))).toEqual(['noop']);
96
+ }),
97
+ );
98
+
99
+ test.provider('occupied case-folded rename refuses before writes or deletes', (scratch) =>
100
+ Effect.gen(function* () {
101
+ existing();
102
+ yield* scratch.deploy(role().pipe(RemovalPolicy.destroy()));
103
+ store.live.set('auth/approle/role/taken', { ...store.live.get(path) });
104
+ bao.seen.length = 0;
105
+ const result = yield* Effect.exit(
106
+ scratch.plan(BaoAuthRole('role', { ...props, name: 'TAKEN' })),
107
+ );
108
+ expect(result._tag).toBe('Failure');
109
+ expect(writesOf(bao.seen)).toEqual([]);
110
+ }),
111
+ );
112
+
113
+ test.provider('existing unowned role refuses without adoption', (scratch) =>
114
+ Effect.gen(function* () {
115
+ existing();
116
+ expect((yield* Effect.exit(scratch.plan(role().pipe(adopt(false)))))._tag).toBe('Failure');
117
+ expect(writesOf(bao.seen)).toEqual([]);
118
+ }),
119
+ );
120
+
121
+ test.provider('destroy deletes only the recorded role and an empty redeploy is inert', (scratch) =>
122
+ Effect.gen(function* () {
123
+ yield* scratch.deploy(role().pipe(RemovalPolicy.destroy()));
124
+ bao.seen.length = 0;
125
+ yield* scratch.deploy(Effect.void);
126
+ expect(writesOf(bao.seen)).toEqual(['DELETE /v1/auth/approle/role/host-cert']);
127
+ expect(store.live.has(path)).toBe(false);
128
+ bao.seen.length = 0;
129
+ yield* scratch.deploy(Effect.void);
130
+ expect(writesOf(bao.seen)).toEqual([]);
131
+ }),
132
+ );
133
+
134
+ test.provider('default retain leaves removed role metadata untouched', (scratch) =>
135
+ Effect.gen(function* () {
136
+ yield* scratch.deploy(role());
137
+ bao.seen.length = 0;
138
+ yield* scratch.deploy(Effect.void);
139
+ expect(writesOf(bao.seen)).toEqual([]);
140
+ expect(store.live.has(path)).toBe(true);
141
+ }),
142
+ );
143
+
144
+ for (const status of [200, 403]) {
145
+ test.provider(`a malformed or denied ${status} read never becomes create or noop`, (scratch) =>
146
+ Effect.gen(function* () {
147
+ existing();
148
+ yield* scratch.deploy(role());
149
+ refused = { status, json: { data: {}, errors: ['refused'] } };
150
+ expect((yield* Effect.exit(scratch.plan(role())))._tag).toBe('Failure');
151
+ expect(writesOf(bao.seen)).toEqual([]);
152
+ refused = undefined;
153
+ }),
154
+ );
155
+ }
@@ -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
  }),