@homeflare/alchemy 0.5.0 → 0.6.0

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 (180) hide show
  1. package/README.md +17 -48
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The `token_*` fields every auth role shares — read once here for Bao.JwtRole and
3
+ * Bao.KubernetesRole, so the two cannot disagree about what a token TTL is.
4
+ *
5
+ * ★ READ FROM openbao v2.6.2 sdk/helper/tokenutil/tokenutil.go:
6
+ * · TokenFields (:80-170) — `token_policies`, `token_bound_cidrs` are comma-string slices;
7
+ * `token_ttl`, `token_max_ttl`, `token_explicit_max_ttl`, `token_period` are duration-seconds;
8
+ * `token_no_default_policy` bool; `token_num_uses` int; `token_type` a string.
9
+ * · ParseTokenFields (:176-262) — EVERY field is `GetOk`: an omitted field KEEPS its stored value.
10
+ * So an update is a merge, and the only way to clear a stale value is to send the default.
11
+ * That is why every field below is sent on every write, resolved to its default.
12
+ * · PopulateTokenData (:265-284) — the read answers seconds for durations, `[]` for empty lists,
13
+ * and `token_type` as a word (sdk/logical/token.go: `default`, `service`, `batch`).
14
+ * ⛔ NO SECRET HERE: policy names, CIDRs, TTLs.
15
+ */
16
+ import { parseDuration, ttlSeconds } from './mount-form.ts';
17
+
18
+ export type BaoTokenType = 'batch' | 'default' | 'service';
19
+
20
+ export interface BaoTokenProps {
21
+ /** Policies on tokens this role issues. Names only. */
22
+ tokenPolicies: readonly string[];
23
+ /** Token TTL (`15m`). Default `0` — the mount's default. */
24
+ tokenTtl?: string;
25
+ /** Token max TTL (`1h`). Default `0` — the mount's max. */
26
+ tokenMaxTtl?: string;
27
+ /** Hard ceiling that renewal cannot pass. Default `0` (none). */
28
+ tokenExplicitMaxTtl?: string;
29
+ /** Periodic tokens renew forever within this period. Default `0` (not periodic). */
30
+ tokenPeriod?: string;
31
+ /** Leave the `default` policy off. Default false. */
32
+ tokenNoDefaultPolicy?: boolean;
33
+ /** CIDRs a token may be USED from. Default none. */
34
+ tokenBoundCidrs?: readonly string[];
35
+ /** Uses before the token dies; `0` is unlimited. Default 0. */
36
+ tokenNumUses?: number;
37
+ /** Default `default` — the mount decides, which means service tokens unless it says otherwise. */
38
+ tokenType?: BaoTokenType;
39
+ }
40
+
41
+ /** The token fields as seconds and sorted sets — both sides of a diff are built by `tokenForm`. */
42
+ export interface BaoTokenForm {
43
+ tokenBoundCidrs: readonly string[];
44
+ tokenExplicitMaxTtlSeconds: number;
45
+ tokenMaxTtlSeconds: number;
46
+ tokenNoDefaultPolicy: boolean;
47
+ tokenNumUses: number;
48
+ tokenPeriodSeconds: number;
49
+ tokenPolicies: readonly string[];
50
+ tokenTtlSeconds: number;
51
+ tokenType: string;
52
+ }
53
+
54
+ const sortedSet = (values: readonly string[]) => [...new Set(values)].sort();
55
+
56
+ /**
57
+ * ⚠️ A HOST CIDR READS BACK BARE. token_bound_cidrs is stored as sockaddr values, and a `/32` (or
58
+ * `/128`) host address renders without its mask. REASONED from go-sockaddr's String(), not
59
+ * measured — so the mask is dropped on BOTH sides, which can only hide a spelling difference.
60
+ */
61
+ const cidr = (value: string) => value.trim().replace(/\/(32|128)$/, '');
62
+
63
+ const strings = (value: unknown): readonly string[] =>
64
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
65
+
66
+ const seconds = (text: string | undefined) => parseDuration(text ?? '0') ?? -1;
67
+
68
+ /**
69
+ * The token fields of an already-normalised form, in one fixed key order — what a role family's own
70
+ * canonical literal spreads, so its digest cannot depend on how the object was assembled.
71
+ */
72
+ export const tokenFields = (input: BaoTokenForm): BaoTokenForm => ({
73
+ tokenBoundCidrs: input.tokenBoundCidrs,
74
+ tokenExplicitMaxTtlSeconds: input.tokenExplicitMaxTtlSeconds,
75
+ tokenMaxTtlSeconds: input.tokenMaxTtlSeconds,
76
+ tokenNoDefaultPolicy: input.tokenNoDefaultPolicy,
77
+ tokenNumUses: input.tokenNumUses,
78
+ tokenPeriodSeconds: input.tokenPeriodSeconds,
79
+ tokenPolicies: input.tokenPolicies,
80
+ tokenTtlSeconds: input.tokenTtlSeconds,
81
+ tokenType: input.tokenType,
82
+ });
83
+
84
+ /** The key order is fixed here, once, for both sides. */
85
+ const tokenForm = (input: BaoTokenForm): BaoTokenForm => ({
86
+ tokenBoundCidrs: sortedSet(input.tokenBoundCidrs.map(cidr)),
87
+ tokenExplicitMaxTtlSeconds: input.tokenExplicitMaxTtlSeconds,
88
+ tokenMaxTtlSeconds: input.tokenMaxTtlSeconds,
89
+ tokenNoDefaultPolicy: input.tokenNoDefaultPolicy,
90
+ tokenNumUses: input.tokenNumUses,
91
+ tokenPeriodSeconds: input.tokenPeriodSeconds,
92
+ tokenPolicies: sortedSet(input.tokenPolicies),
93
+ tokenTtlSeconds: input.tokenTtlSeconds,
94
+ tokenType: input.tokenType,
95
+ });
96
+
97
+ export const tokenFormOfProps = (props: BaoTokenProps): BaoTokenForm =>
98
+ tokenForm({
99
+ tokenBoundCidrs: props.tokenBoundCidrs ?? [],
100
+ tokenExplicitMaxTtlSeconds: seconds(props.tokenExplicitMaxTtl),
101
+ tokenMaxTtlSeconds: seconds(props.tokenMaxTtl),
102
+ tokenNoDefaultPolicy: props.tokenNoDefaultPolicy ?? false,
103
+ tokenNumUses: props.tokenNumUses ?? 0,
104
+ tokenPeriodSeconds: seconds(props.tokenPeriod),
105
+ tokenPolicies: props.tokenPolicies,
106
+ tokenTtlSeconds: seconds(props.tokenTtl),
107
+ tokenType: props.tokenType ?? 'default',
108
+ });
109
+
110
+ export const tokenFormOfLive = (live: Record<string, unknown>): BaoTokenForm =>
111
+ tokenForm({
112
+ tokenBoundCidrs: strings(live['token_bound_cidrs']),
113
+ tokenExplicitMaxTtlSeconds: ttlSeconds(live['token_explicit_max_ttl']) ?? -1,
114
+ tokenMaxTtlSeconds: ttlSeconds(live['token_max_ttl']) ?? -1,
115
+ tokenNoDefaultPolicy: live['token_no_default_policy'] === true,
116
+ tokenNumUses: typeof live['token_num_uses'] === 'number' ? live['token_num_uses'] : -1,
117
+ tokenPeriodSeconds: ttlSeconds(live['token_period']) ?? -1,
118
+ tokenPolicies: strings(live['token_policies']),
119
+ tokenTtlSeconds: ttlSeconds(live['token_ttl']) ?? -1,
120
+ tokenType: typeof live['token_type'] === 'string' ? live['token_type'] : '',
121
+ });
122
+
123
+ /**
124
+ * The token half of a role write — every field, defaults included (the merge ⚠️ above). Durations
125
+ * go over as the author's own strings; lists as JSON arrays, which a comma-string-slice accepts.
126
+ */
127
+ export const tokenBody = (props: BaoTokenProps): Record<string, unknown> => ({
128
+ token_bound_cidrs: [...(props.tokenBoundCidrs ?? [])],
129
+ token_explicit_max_ttl: props.tokenExplicitMaxTtl ?? '0',
130
+ token_max_ttl: props.tokenMaxTtl ?? '0',
131
+ token_no_default_policy: props.tokenNoDefaultPolicy ?? false,
132
+ token_num_uses: props.tokenNumUses ?? 0,
133
+ token_period: props.tokenPeriod ?? '0',
134
+ token_policies: [...props.tokenPolicies],
135
+ token_ttl: props.tokenTtl ?? '0',
136
+ token_type: props.tokenType ?? 'default',
137
+ });
138
+
139
+ /** Refusals the token half can know without a server. */
140
+ export const tokenProblems = (props: BaoTokenProps): readonly string[] => {
141
+ const found: string[] = [];
142
+ const durations = {
143
+ tokenExplicitMaxTtl: props.tokenExplicitMaxTtl,
144
+ tokenMaxTtl: props.tokenMaxTtl,
145
+ tokenPeriod: props.tokenPeriod,
146
+ tokenTtl: props.tokenTtl,
147
+ };
148
+ for (const [field, text] of Object.entries(durations)) {
149
+ if (seconds(text) < 0) found.push(`${field}=${text ?? ''} is not a duration (0 or <n>s|m|h|d)`);
150
+ }
151
+ const ttl = seconds(props.tokenTtl);
152
+ const max = seconds(props.tokenMaxTtl);
153
+ if (ttl > 0 && max > 0 && ttl > max) found.push('tokenTtl is longer than tokenMaxTtl');
154
+ if (
155
+ props.tokenType === 'batch' &&
156
+ (seconds(props.tokenPeriod) > 0 || (props.tokenNumUses ?? 0) > 0)
157
+ ) {
158
+ found.push('a batch token cannot be periodic or use-limited (tokenutil.go:234-241)');
159
+ }
160
+ return found;
161
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * assertBaoIdentity against a fake. The parts worth pinning: health goes out with NO token and NO
3
+ * namespace header (root-only API), a mismatch refuses before anything else is asked, and a
4
+ * missing namespace is a refusal rather than a quiet fall-through to root.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import {
9
+ BaoIdentityError,
10
+ HEALTH_PATH,
11
+ NAMESPACE_PROBE_PATH,
12
+ assertBaoIdentity,
13
+ assertBaoIdentityEffect,
14
+ } from './bao-identity.ts';
15
+ import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
16
+
17
+ const HEALTHY = {
18
+ cluster_id: '00000000-0000-4000-8000-000000000001',
19
+ cluster_name: 'vault-example',
20
+ initialized: true,
21
+ sealed: false,
22
+ standby: false,
23
+ version: '2.6.2',
24
+ };
25
+
26
+ const routes =
27
+ (health: Reply, probe: Reply = { json: { data: { auth: {}, secret: {} } }, status: 200 }) =>
28
+ (seen: Seen) =>
29
+ seen.path.startsWith('/v1/sys/health') ? health : probe;
30
+
31
+ const EXPECT = { clusterName: 'vault-example', namespace: 'team-a' };
32
+ const envOf = (address: string) => ({
33
+ BAO_ADDR: address,
34
+ BAO_NAMESPACE: 'team-a',
35
+ BAO_TOKEN: 'placeholder-token',
36
+ });
37
+
38
+ const failureOf = async (promise: Promise<unknown>) => {
39
+ const error = await promise;
40
+ assert.ok(error instanceof BaoIdentityError);
41
+ assert.match(error.message, /^REFUSING: /);
42
+ return error;
43
+ };
44
+
45
+ describe('assertBaoIdentity', () => {
46
+ it('passes a matching vault, sending no token anywhere and no namespace to health', async () => {
47
+ await withFake(routes({ json: HEALTHY, status: 200 }), async (bao) => {
48
+ const identity = await run(envOf(bao.address), assertBaoIdentityEffect(EXPECT));
49
+ assert.equal(identity.clusterName, 'vault-example');
50
+ assert.equal(identity.namespace, 'team-a');
51
+ assert.equal(identity.version, '2.6.2');
52
+ const [health, probe] = bao.seen;
53
+ assert.equal(health?.path, `/v1/${HEALTH_PATH}`);
54
+ assert.equal(health?.headers.get('x-vault-namespace'), null);
55
+ assert.equal(probe?.path, `/v1/${NAMESPACE_PROBE_PATH}`);
56
+ assert.equal(probe?.headers.get('x-vault-namespace'), 'team-a');
57
+ for (const seen of bao.seen) assert.equal(seen.headers.get('x-vault-token'), null);
58
+ });
59
+ });
60
+
61
+ it('refuses the wrong cluster and asks nothing more', async () => {
62
+ const other = { ...HEALTHY, cluster_name: 'vault-other' };
63
+ await withFake(routes({ json: other, status: 200 }), async (bao) => {
64
+ const error = await failureOf(
65
+ runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
66
+ );
67
+ assert.equal(error.reason, 'mismatch');
68
+ assert.match(error.message, /vault-other/);
69
+ assert.equal(bao.seen.length, 1);
70
+ });
71
+ });
72
+
73
+ it('refuses a sealed vault as unconfirmed, because sealed hides cluster_name', async () => {
74
+ const sealed = { initialized: true, sealed: true, standby: true, version: '2.6.2' };
75
+ await withFake(routes({ json: sealed, status: 200 }), async (bao) => {
76
+ const error = await failureOf(
77
+ runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
78
+ );
79
+ assert.equal(error.reason, 'unconfirmed');
80
+ });
81
+ });
82
+
83
+ it('refuses a BAO_NAMESPACE that is not the expected one without calling out', async () => {
84
+ await withFake(routes({ json: HEALTHY, status: 200 }), async (bao) => {
85
+ const env = { ...envOf(bao.address), BAO_NAMESPACE: '' };
86
+ const error = await failureOf(runFailure(env, assertBaoIdentityEffect(EXPECT)));
87
+ assert.equal(error.reason, 'mismatch');
88
+ assert.match(error.message, /`root`/);
89
+ assert.equal(bao.seen.length, 0);
90
+ });
91
+ });
92
+
93
+ it('refuses when the namespace does not exist on that cluster', async () => {
94
+ const missing = { json: { errors: ['namespace not found'] }, status: 404 };
95
+ await withFake(routes({ json: HEALTHY, status: 200 }, missing), async (bao) => {
96
+ const error = await failureOf(
97
+ runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
98
+ );
99
+ assert.equal(error.reason, 'mismatch');
100
+ assert.match(error.message, /no namespace `team-a`/);
101
+ });
102
+ });
103
+
104
+ it('skips the probe for the root namespace, however it is spelled', async () => {
105
+ await withFake(routes({ json: HEALTHY, status: 200 }), async (bao) => {
106
+ const env = { BAO_ADDR: bao.address, BAO_NAMESPACE: 'root' };
107
+ await run(env, assertBaoIdentityEffect({ clusterName: 'vault-example', namespace: '/' }));
108
+ assert.equal(bao.seen.length, 1);
109
+ });
110
+ });
111
+
112
+ it('refuses a 200 that is not an OpenBao health body', async () => {
113
+ await withFake(routes({ json: { ok: true }, status: 200 }), async (bao) => {
114
+ const error = await failureOf(
115
+ runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
116
+ );
117
+ assert.equal(error.reason, 'unconfirmed');
118
+ });
119
+ });
120
+
121
+ it('rejects the Promise form with the BaoIdentityError itself', async () => {
122
+ const other = { ...HEALTHY, cluster_name: 'vault-other' };
123
+ await withFake(routes({ json: other, status: 200 }), async (bao) => {
124
+ const error = await assertBaoIdentity({ ...EXPECT, env: envOf(bao.address) }).then(
125
+ () => undefined,
126
+ (rejection: unknown) => rejection,
127
+ );
128
+ await failureOf(Promise.resolve(error));
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Refuse to plan against the wrong vault: assert the `cluster_name` and namespace a stack expects
3
+ * BEFORE any Bao.* resource reads or writes.
4
+ *
5
+ * ⛔ WHY IT EXISTS. A `BAO_ADDR` left exported for one vault (or a `BAO_AGENT_ADDR`, which wins)
6
+ * would otherwise apply a stack written for another vault to it, and every family here would
7
+ * plan CREATEs against the wrong server with a clean conscience (the estate's site-config plan,
8
+ * "Identity checks on every live plan").
9
+ * A missing `BAO_NAMESPACE` does the same thing one level down: the stack lands in root.
10
+ *
11
+ * ★ `sys/health`, UNAUTHENTICATED, WITH NO TOKEN AND NO NAMESPACE HEADER. READ FROM openbao v2.6.2:
12
+ * · http/sys_health.go:143-165 — `cluster_name` and `cluster_id` are filled ONLY while unsealed,
13
+ * and the body is top-level JSON, not wrapped in `data`.
14
+ * · http/handler.go:108-124, :405-411 — `health` is a root-only API: a request carrying
15
+ * `X-Vault-Namespace` is answered 400 "operation unavailable in namespaces". So the header is
16
+ * stripped for this one call, whatever BAO_NAMESPACE says.
17
+ * · The query asks for 200 when sealed, uninitialised or standby (`sealedcode`, `uninitcode`,
18
+ * `standbyok`, :78-110), so the body is always there to read and the refusal can say WHICH.
19
+ * ⛔ NO TOKEN IS SENT. Until this check passes, the server has not even claimed to be ours, and a
20
+ * token sent to the wrong server is a token handed over.
21
+ * ⚠️ IT CATCHES A MISTARGET, NOT AN IMPOSTOR (review 2026-09-21). `cluster_name` is answered to
22
+ * anyone, unauthenticated, so a hostile server can echo the expected one; passing proves the
23
+ * address is configured right, never who answers. TLS to a trusted certificate is what
24
+ * authenticates the server — keep an https BAO_ADDR on anything that is not loopback or a socket.
25
+ * ★ THE NAMESPACE IS CHECKED TWICE. Locally first: the namespace every later call will send (the
26
+ * same bao-address.ts resolution) must BE the expected one. Then remotely: unauthenticated
27
+ * `sys/internal/ui/mounts` with that namespace header answers 200 when the namespace exists and
28
+ * 404 "namespace not found" when it does not (vault/request_handling.go:607-612; the path is in
29
+ * the system backend's Unauthenticated list, vault/logical_system.go:95). Only the status is read.
30
+ * REASONED FROM SOURCE, not measured against a live server.
31
+ * ⚠️ IT PROVES THE CLUSTER, NOT THE NODE. A standby answers with the cluster's name, which is the
32
+ * point: a standby forwards to the same vault.
33
+ */
34
+ import * as Effect from 'effect/Effect';
35
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
36
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
37
+ import { type BaoEnvironment, resolveAddress } from './bao-address.ts';
38
+ import { BaoEnv, baoCall } from './bao-http.ts';
39
+ import { canonicalNamespace, envNamespace, namespaceLabel } from './bao-namespace.ts';
40
+ import type { BaoError } from './bao-status.ts';
41
+
42
+ /** `input` a blank expectation · `mismatch` the wrong vault · `unconfirmed` could not tell · `unreachable` no answer. */
43
+ export type BaoIdentityFailure = 'input' | 'mismatch' | 'unconfirmed' | 'unreachable';
44
+
45
+ /** ⛔ Every message starts `REFUSING:`, and none carries a token — only the address and names. */
46
+ export class BaoIdentityError extends Error {
47
+ constructor(
48
+ readonly reason: BaoIdentityFailure,
49
+ detail: string,
50
+ ) {
51
+ super(`REFUSING: ${detail}`);
52
+ this.name = 'BaoIdentityError';
53
+ }
54
+ }
55
+
56
+ export interface BaoIdentityExpected {
57
+ /** The vault's `cluster_name` (server config `cluster_name`, or the one generated at init). */
58
+ readonly clusterName: string;
59
+ /** The namespace the stack targets: `team-a`, or `''` / `root` for the root namespace. */
60
+ readonly namespace: string;
61
+ }
62
+
63
+ /** What the vault said about itself once every check passed. */
64
+ export interface BaoIdentity {
65
+ /** The resolved address, without any token. */
66
+ readonly address: string;
67
+ readonly clusterName: string;
68
+ readonly clusterId: string;
69
+ readonly version: string;
70
+ /** Canonical: `''` is root. */
71
+ readonly namespace: string;
72
+ }
73
+
74
+ export const HEALTH_PATH = 'sys/health?standbyok=true&sealedcode=200&uninitcode=200';
75
+ export const NAMESPACE_PROBE_PATH = 'sys/internal/ui/mounts';
76
+
77
+ const addressOf = (env: BaoEnvironment) => {
78
+ const address = resolveAddress(env);
79
+ return address.socket === undefined ? address.base : `unix://${address.socket}`;
80
+ };
81
+
82
+ const unreachable =
83
+ (address: string, what: string) =>
84
+ (error: BaoError): BaoIdentityError =>
85
+ new BaoIdentityError('unreachable', `${address} did not answer ${what}: ${error.message}`);
86
+
87
+ const text = (value: unknown) => (typeof value === 'string' ? value : '');
88
+
89
+ /** Read a health body into the one question asked of it. Pure, for the tests. */
90
+ export const checkHealth = (
91
+ address: string,
92
+ expectedCluster: string,
93
+ body: Record<string, unknown> | undefined,
94
+ ): { readonly clusterId: string; readonly version: string } | BaoIdentityError => {
95
+ if (body === undefined || typeof body['sealed'] !== 'boolean') {
96
+ return new BaoIdentityError(
97
+ 'unconfirmed',
98
+ `${address} answered sys/health, but not as OpenBao.`,
99
+ );
100
+ }
101
+ if (body['initialized'] === false) {
102
+ return new BaoIdentityError('unconfirmed', `${address} is not initialised.`);
103
+ }
104
+ if (body['sealed'] === true) {
105
+ return new BaoIdentityError(
106
+ 'unconfirmed',
107
+ `${address} is sealed, and a sealed vault does not report its cluster_name.`,
108
+ );
109
+ }
110
+ const clusterName = text(body['cluster_name']);
111
+ if (clusterName !== expectedCluster) {
112
+ return new BaoIdentityError(
113
+ 'mismatch',
114
+ `${address} answers as cluster \`${clusterName || '(none)'}\`, but this stack expects ` +
115
+ `\`${expectedCluster}\`. Check BAO_ADDR and BAO_AGENT_ADDR (the agent address wins).`,
116
+ );
117
+ }
118
+ return { clusterId: text(body['cluster_id']), version: text(body['version']) };
119
+ };
120
+
121
+ /**
122
+ * The check, as an Effect under BaoEnv (process.env unless provided). Fails with BaoIdentityError
123
+ * and nothing else.
124
+ */
125
+ export const assertBaoIdentityEffect = (
126
+ expected: BaoIdentityExpected,
127
+ ): Effect.Effect<BaoIdentity, BaoIdentityError, HttpClient.HttpClient> =>
128
+ Effect.gen(function* () {
129
+ const env = yield* BaoEnv;
130
+ const address = addressOf(env);
131
+ const clusterName = expected.clusterName.trim();
132
+ if (clusterName === '') {
133
+ return yield* Effect.fail(
134
+ new BaoIdentityError('input', 'clusterName is empty, so there is nothing to compare.'),
135
+ );
136
+ }
137
+ const namespace = canonicalNamespace(expected.namespace);
138
+ const sent = envNamespace(env);
139
+ if (sent !== namespace) {
140
+ return yield* Effect.fail(
141
+ new BaoIdentityError(
142
+ 'mismatch',
143
+ `BAO_NAMESPACE resolves to \`${namespaceLabel(sent)}\`, but this stack expects ` +
144
+ `\`${namespaceLabel(namespace)}\`.`,
145
+ ),
146
+ );
147
+ }
148
+
149
+ const anonymous = { ...env, BAO_NAMESPACE: '', BAO_TOKEN: '' };
150
+ const body = yield* baoCall('read', 'GET', HEALTH_PATH).pipe(
151
+ Effect.provideService(BaoEnv, anonymous),
152
+ Effect.mapError(unreachable(address, 'sys/health')),
153
+ );
154
+ const health = checkHealth(address, clusterName, body);
155
+ if (health instanceof BaoIdentityError) return yield* Effect.fail(health);
156
+
157
+ if (namespace !== '') {
158
+ const scoped = { ...env, BAO_NAMESPACE: namespace, BAO_TOKEN: '' };
159
+ const probe = yield* baoCall('read', 'GET', NAMESPACE_PROBE_PATH).pipe(
160
+ Effect.provideService(BaoEnv, scoped),
161
+ Effect.mapError(unreachable(address, `the namespace probe for \`${namespace}\``)),
162
+ );
163
+ if (probe === undefined) {
164
+ return yield* Effect.fail(
165
+ new BaoIdentityError(
166
+ 'mismatch',
167
+ `cluster \`${clusterName}\` at ${address} has no namespace \`${namespace}\`.`,
168
+ ),
169
+ );
170
+ }
171
+ }
172
+ return { address, clusterName, namespace, ...health };
173
+ });
174
+
175
+ /**
176
+ * `assertBaoIdentityEffect` for a plain script. `env` defaults to `process.env`, read at call time.
177
+ * ★ Rejects with the BaoIdentityError itself (Effect.runPromise, as in approle-login.ts).
178
+ *
179
+ * await assertBaoIdentity({ clusterName: 'vault-example', namespace: 'team-a' });
180
+ */
181
+ export const assertBaoIdentity = (
182
+ input: BaoIdentityExpected & { readonly env?: BaoEnvironment },
183
+ ): Promise<BaoIdentity> => {
184
+ const effect = assertBaoIdentityEffect(input);
185
+ const scoped =
186
+ input.env === undefined ? effect : Effect.provideService(effect, BaoEnv, input.env);
187
+ return Effect.runPromise(scoped.pipe(Effect.provide(FetchHttpClient.layer)));
188
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Namespace spellings, made comparable. `homeflare`, `/homeflare/` and `homeflare/` are one
3
+ * namespace to OpenBao, and `root` (or nothing) is the root namespace.
4
+ *
5
+ * ★ READ FROM openbao v2.6.2: namespace.Canonicalize trims the leading slash and appends a trailing
6
+ * one, and ResolveNamespaceFromRequest (vault/namespace_store.go:1573-1598) re-routes a header
7
+ * that is literally `root/` to the root namespace. A namespace's own `namespace_path` in an API
8
+ * answer is that canonical form: `''` for root, `a/b/` below it (vault/login_mfa.go
9
+ * mfaConfigToMap and mfaLoginEnforcementConfigToMap).
10
+ */
11
+ import { type BaoEnvironment, resolveAddress } from './bao-address.ts';
12
+ import { trimSlashes } from './mount-path.ts';
13
+
14
+ /** The bare name: no leading or trailing slash, and `''` for the root namespace. */
15
+ export const canonicalNamespace = (namespace: string | undefined): string => {
16
+ const bare = trimSlashes((namespace ?? '').trim());
17
+ return bare === 'root' ? '' : bare;
18
+ };
19
+
20
+ /** OpenBao's `namespace_path` rendering of the same namespace: `''` or `a/b/`. */
21
+ export const namespacePath = (namespace: string | undefined): string => {
22
+ const bare = canonicalNamespace(namespace);
23
+ return bare === '' ? '' : `${bare}/`;
24
+ };
25
+
26
+ /** The namespace every call under `env` targets, canonical. */
27
+ export const envNamespace = (env: BaoEnvironment): string =>
28
+ canonicalNamespace(resolveAddress(env).namespace);
29
+
30
+ /** How a namespace reads in a message: `root` rather than an empty pair of backticks. */
31
+ export const namespaceLabel = (namespace: string): string =>
32
+ namespace === '' ? 'root' : namespace;
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { accountResource, bucketResource, zoneResource } from './cloudflare-group-scope.ts';
16
16
  import type { AccountSpec, RoleTemplate, RolesConfig } from './cloudflare-roles-config.ts';
17
+ import { trimRuns } from './mount-path.ts';
17
18
 
18
19
  /** A zone as the expansion needs it — what cfhf.py load_account listed (cfhf.py:134-146). */
19
20
  export interface CloudflareZone {
@@ -73,11 +74,7 @@ export interface ExpandedRole {
73
74
  }
74
75
 
75
76
  /** cfroles.py:18-20 — `example.com` → `homeflare-dev`. */
76
- export const slug = (name: string) =>
77
- name
78
- .toLowerCase()
79
- .replace(/[^a-z0-9]+/g, '-')
80
- .replace(/^-+|-+$/g, '');
77
+ export const slug = (name: string) => trimRuns(name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), '-');
81
78
 
82
79
  /**
83
80
  * cfroles.py:23-38. ⛔ THE `-internal` SUFFIX IS WHAT KEEPS TWO ZONES OF ONE NAME APART. Slugging
@@ -0,0 +1,121 @@
1
+ /**
2
+ * hostAppRoles: one role per host, named `<class>--<host>`, and never a secret_id that does not
3
+ * expire. The hosts and classes here are placeholders, not the estate's.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import {
8
+ HOST_ROLE_SEPARATOR,
9
+ type HostRoleClass,
10
+ hostAppRoles,
11
+ hostRoleName,
12
+ } from './host-approles.ts';
13
+ import { parseDuration } from './mount-form.ts';
14
+
15
+ const INFRA: HostRoleClass = {
16
+ policies: ['host-cert', 'pve-node', 'host-cert'],
17
+ secretIdTtl: '2160h',
18
+ tokenMaxTtl: '1h',
19
+ tokenTtl: '15m',
20
+ };
21
+ const SIGNER: HostRoleClass = {
22
+ policies: ['ssh-signer'],
23
+ secretIdTtl: '90d',
24
+ tokenMaxTtl: '30m',
25
+ tokenTtl: '10m',
26
+ };
27
+ const INPUT = {
28
+ classes: { 'operator-signer': SIGNER, 'pve-node': INFRA },
29
+ hosts: [
30
+ { class: 'pve-node', name: 'node-b' },
31
+ { class: 'pve-node', name: 'node-a' },
32
+ { class: 'operator-signer', name: 'workstation-1' },
33
+ ],
34
+ };
35
+
36
+ describe('hostAppRoles', () => {
37
+ it('makes one role per host, named <class>--<host>, sorted', () => {
38
+ const roles = hostAppRoles(INPUT);
39
+ assert.deepEqual(
40
+ roles.map((role) => role.name),
41
+ ['operator-signer--workstation-1', 'pve-node--node-a', 'pve-node--node-b'],
42
+ );
43
+ assert.equal(hostRoleName('pve-node', 'node-a'), 'pve-node--node-a');
44
+ });
45
+
46
+ it('gives every role an expiring secret_id — none has secret_id_ttl 0', () => {
47
+ for (const role of hostAppRoles(INPUT)) {
48
+ const seconds = parseDuration(role.secretIdTtl);
49
+ assert.ok(
50
+ seconds !== undefined && seconds > 0,
51
+ `${role.name} secret_id_ttl ${role.secretIdTtl}`,
52
+ );
53
+ assert.equal(role.bindSecretId, true);
54
+ }
55
+ });
56
+
57
+ it("carries the class's policies, de-duplicated, and its TTLs", () => {
58
+ const role = hostAppRoles(INPUT).find((each) => each.name === 'pve-node--node-a');
59
+ assert.deepEqual(role?.tokenPolicies, ['host-cert', 'pve-node']);
60
+ assert.equal(role?.tokenTtl, '15m');
61
+ assert.equal(role?.secretIdNumUses, 0);
62
+ });
63
+
64
+ it('splits every name back into exactly one class and host', () => {
65
+ for (const role of hostAppRoles(INPUT)) {
66
+ assert.equal(role.name.split(HOST_ROLE_SEPARATOR).length, 2, role.name);
67
+ }
68
+ });
69
+
70
+ it('refuses a secret_id that never expires, or does not parse', () => {
71
+ for (const secretIdTtl of ['0', '0s', 'forever']) {
72
+ assert.throws(
73
+ () =>
74
+ hostAppRoles({
75
+ ...INPUT,
76
+ classes: { ...INPUT.classes, 'pve-node': { ...INFRA, secretIdTtl } },
77
+ }),
78
+ /must be a duration above zero/,
79
+ );
80
+ }
81
+ });
82
+
83
+ it('refuses unknown classes, duplicate hosts and names that would blur the separator', () => {
84
+ const bad = [
85
+ { class: 'nope', name: 'node-c' },
86
+ { class: 'pve-node', name: 'node-a' },
87
+ { class: 'pve-node', name: 'node-a' },
88
+ { class: 'pve-node', name: 'node--x' },
89
+ { class: 'pve-node', name: 'Node-Y' },
90
+ ];
91
+ assert.throws(
92
+ () => hostAppRoles({ ...INPUT, hosts: bad }),
93
+ (error: unknown) => {
94
+ assert.ok(error instanceof Error);
95
+ assert.match(error.message, /unknown class `nope`/);
96
+ assert.match(error.message, /`node-a` is listed twice/);
97
+ assert.match(error.message, /`node--x` is not lowercase/);
98
+ assert.match(error.message, /`Node-Y` is not lowercase/);
99
+ return true;
100
+ },
101
+ );
102
+ });
103
+
104
+ it('refuses a class with no policies, with root, or with ttl above max', () => {
105
+ const classes = {
106
+ empty: { ...INFRA, policies: [] },
107
+ rooted: { ...INFRA, policies: ['root'] },
108
+ upside: { ...INFRA, tokenMaxTtl: '5m', tokenTtl: '1h' },
109
+ };
110
+ assert.throws(
111
+ () => hostAppRoles({ classes, hosts: [] }),
112
+ (error: unknown) => {
113
+ assert.ok(error instanceof Error);
114
+ assert.match(error.message, /`empty` has no policies/);
115
+ assert.match(error.message, /`rooted` grants `root`/);
116
+ assert.match(error.message, /`upside` tokenTtl is longer/);
117
+ return true;
118
+ },
119
+ );
120
+ });
121
+ });