@homeflare/alchemy 0.4.0 → 0.5.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 (53) hide show
  1. package/README.md +57 -0
  2. package/dist/openbao/approle-login-form.d.ts +72 -0
  3. package/dist/openbao/approle-login-form.d.ts.map +1 -0
  4. package/dist/openbao/approle-login-result.d.ts +39 -0
  5. package/dist/openbao/approle-login-result.d.ts.map +1 -0
  6. package/dist/openbao/approle-login.d.ts +77 -0
  7. package/dist/openbao/approle-login.d.ts.map +1 -0
  8. package/dist/openbao/bao-http.d.ts +2 -0
  9. package/dist/openbao/bao-http.d.ts.map +1 -1
  10. package/dist/openbao/index.d.ts +7 -0
  11. package/dist/openbao/index.d.ts.map +1 -1
  12. package/dist/openbao/index.js +589 -305
  13. package/dist/openbao/index.js.map +16 -8
  14. package/dist/openbao/mount-path.d.ts +14 -0
  15. package/dist/openbao/mount-path.d.ts.map +1 -0
  16. package/dist/openbao/plugin-form.d.ts +60 -0
  17. package/dist/openbao/plugin-form.d.ts.map +1 -0
  18. package/dist/openbao/plugin-reconcile.d.ts +18 -0
  19. package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
  20. package/dist/openbao/plugin-wire.d.ts +45 -0
  21. package/dist/openbao/plugin-wire.d.ts.map +1 -0
  22. package/dist/openbao/plugin.d.ts +31 -0
  23. package/dist/openbao/plugin.d.ts.map +1 -0
  24. package/dist/openbao/ssh-role-form.d.ts +6 -5
  25. package/dist/openbao/ssh-role-form.d.ts.map +1 -1
  26. package/dist/openbao/ssh-role.d.ts.map +1 -1
  27. package/dist/proxmox/index.d.ts +23 -20
  28. package/dist/proxmox/index.d.ts.map +1 -1
  29. package/dist/proxmox/index.js +19 -1
  30. package/dist/proxmox/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/openbao/approle-login-form.test.ts +64 -0
  33. package/src/openbao/approle-login-form.ts +153 -0
  34. package/src/openbao/approle-login-result.ts +96 -0
  35. package/src/openbao/approle-login.test.ts +200 -0
  36. package/src/openbao/approle-login.ts +156 -0
  37. package/src/openbao/auth-role-form.test.ts +42 -0
  38. package/src/openbao/bao-http.ts +2 -0
  39. package/src/openbao/index.ts +13 -0
  40. package/src/openbao/mount-path.test.ts +23 -0
  41. package/src/openbao/mount-path.ts +25 -0
  42. package/src/openbao/mount-wire.test.ts +13 -0
  43. package/src/openbao/plugin-form.test.ts +86 -0
  44. package/src/openbao/plugin-form.ts +164 -0
  45. package/src/openbao/plugin-reconcile.ts +92 -0
  46. package/src/openbao/plugin-wire.test.ts +146 -0
  47. package/src/openbao/plugin-wire.ts +65 -0
  48. package/src/openbao/plugin.ts +100 -0
  49. package/src/openbao/ssh-role-form.test.ts +105 -0
  50. package/src/openbao/ssh-role-form.ts +18 -16
  51. package/src/openbao/ssh-role.ts +3 -4
  52. package/src/proxmox/index.test.ts +53 -0
  53. package/src/proxmox/index.ts +26 -20
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pure decisions behind appRoleLogin: which failure a status is, which mounts may be spliced
3
+ * into a URL, and what redaction removes. No server — approle-login.test.ts drives the fake.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import { failureOf, loginPath, mountProblem, redact } from './approle-login-form.ts';
8
+
9
+ describe('failureOf', () => {
10
+ it('reads 0 as unreachable, 2xx as a bad response, anything else as refused', () => {
11
+ assert.equal(failureOf(0), 'unreachable');
12
+ assert.equal(failureOf(200), 'response');
13
+ assert.equal(failureOf(204), 'response');
14
+ assert.equal(failureOf(400), 'refused');
15
+ assert.equal(failureOf(503), 'refused');
16
+ });
17
+ });
18
+
19
+ describe('mountProblem', () => {
20
+ it('accepts plain and nested mounts, trimming outer slashes', () => {
21
+ for (const mount of [undefined, 'approle', '/approle-hosts/', 'team/approle']) {
22
+ assert.equal(mountProblem(mount), undefined, String(mount));
23
+ }
24
+ assert.equal(loginPath('/team/approle/'), 'auth/team/approle/login');
25
+ });
26
+
27
+ it('refuses anything the URL parser would move or cut short', () => {
28
+ for (const mount of ['', '/', '..', 'a/../b', './a', 'a//b', 'a?b', 'a#b', 'a%2e', 'a b']) {
29
+ assert.notEqual(mountProblem(mount), undefined, mount);
30
+ }
31
+ });
32
+
33
+ it('trims long runs of slashes in linear time (no polynomial regex on caller input)', () => {
34
+ const run = '/'.repeat(200_000);
35
+ const started = performance.now();
36
+ assert.equal(loginPath(`${run}approle${run}`), 'auth/approle/login');
37
+ assert.notEqual(mountProblem(`${run}a${run}x`), undefined);
38
+ assert.ok(performance.now() - started < 1_000, 'slash trimming took over a second');
39
+ });
40
+ });
41
+
42
+ describe('redact', () => {
43
+ it('removes the raw value wherever it appears', () => {
44
+ assert.deepEqual(redact(['x secret-1 y secret-1'], ['secret-1']), [
45
+ 'x [redacted] y [redacted]',
46
+ ]);
47
+ });
48
+
49
+ it('removes the %q form OpenBao echoes (path_login.go:291), escapes and all', () => {
50
+ const secret = 'a"b\\c';
51
+ const echoed = `invalid secret_id "a\\"b\\\\c"`;
52
+ assert.deepEqual(redact([echoed], [secret]), ['invalid secret_id "[redacted]"']);
53
+ });
54
+
55
+ it('redacts the longer value first, so a shorter one cannot split it', () => {
56
+ const [text] = redact(['role abc and secret abcdef'], ['abc', 'abcdef']);
57
+ assert.equal(text, 'role [redacted] and secret [redacted]');
58
+ assert.ok(!text?.includes('def'));
59
+ });
60
+
61
+ it('ignores an empty value rather than redacting every gap', () => {
62
+ assert.deepEqual(redact(['kept'], ['']), ['kept']);
63
+ });
64
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The pure half of an AppRole login's INPUT and FAILURE: what counts as a usable input, and how a
3
+ * failure is described without the credential in it. Split from approle-login.ts so each decision
4
+ * is testable without a server; what a success hands back is approle-login-result.ts.
5
+ *
6
+ * ★ READ FROM openbao v2.6.2, NOT RECALLED:
7
+ * · docs/api/auth/approle.mdx "Login with AppRole" — `role_id` and `secret_id` in, an `auth` block
8
+ * out.
9
+ * · api/auth/approle/approle.go:145-146 — the official Go helper writes `auth/<mount>/login`, and
10
+ * the mount is `approle` unless the caller says otherwise. (api/*.go cited here diffed identical
11
+ * between v2.6.0 and v2.6.2.)
12
+ */
13
+ import { trimSlashes } from './mount-path.ts';
14
+
15
+ /** Why a login or a revoke failed. */
16
+ export type BaoLoginFailure =
17
+ /** Refused before any request was sent: an empty or padded value, or a mount that is not a path. */
18
+ | 'input'
19
+ /** OpenBao (or something at BAO_ADDR) answered with a non-2xx status. */
20
+ | 'refused'
21
+ /** No answer at all — transport, timeout, or an address nothing listens on. */
22
+ | 'unreachable'
23
+ /** A 2xx that is not a usable answer: not JSON, no auth block, or MFA still required. */
24
+ | 'response';
25
+
26
+ /**
27
+ * The failure a transport-level BaoError stands for.
28
+ *
29
+ * ⚠️ A 2xx CAN FAIL TOO, AND IT IS NOT A REFUSAL. bao-status.ts settle fails a success whose body is
30
+ * not a JSON object — the shape of a BAO_ADDR that points at some other web server. The first
31
+ * version mapped every non-zero status to `refused`, so that read as `refused 200`: a server that
32
+ * said yes, reported as one that said no.
33
+ */
34
+ export const failureOf = (status: number): BaoLoginFailure =>
35
+ status === 0 ? 'unreachable' : status >= 200 && status < 300 ? 'response' : 'refused';
36
+
37
+ /**
38
+ * A failed login or revoke.
39
+ *
40
+ * ⛔ IT NEVER CARRIES A CREDENTIAL. It is built from the operation name, a status and a list of
41
+ * strings that have been through `redact` — never from the request, a header, or a `cause`, for
42
+ * the reason bao-status.ts gives for BaoError.
43
+ */
44
+ export class BaoLoginError extends Error {
45
+ constructor(
46
+ readonly reason: BaoLoginFailure,
47
+ /** The HTTP status, or 0 when none applies (input, unreachable, an unusable auth block). */
48
+ readonly status: number,
49
+ readonly errors: readonly string[],
50
+ operation: string,
51
+ ) {
52
+ const code = status === 0 ? reason : `${reason} ${String(status)}`;
53
+ super(`${operation}: ${code}: ${errors.join('; ') || '(no errors given)'}`);
54
+ this.name = 'BaoLoginError';
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Why a value cannot be sent, or undefined when it can.
60
+ *
61
+ * ⚠️ PADDING IS REFUSED, NOT TRIMMED. `await Bun.file(path).text()` keeps a secret_id file's trailing
62
+ * newline, and OpenBao answers that with "invalid role or secret ID" — the same words it uses for
63
+ * a revoked or expired credential, so the real cause is invisible from the error. Trimming quietly
64
+ * would send a different value from the one the caller holds; refusing names the problem.
65
+ * ⚠️ `unknown`, NOT `string`: a plain script passes `process.env.X`, which is `undefined` when unset.
66
+ */
67
+ export const refusal = (label: string, value: unknown): string | undefined => {
68
+ if (typeof value !== 'string' || value === '') return `${label} is empty`;
69
+ if (value.trim() === '') return `${label} is only whitespace`;
70
+ if (value.trim() !== value) {
71
+ return `${label} has leading or trailing whitespace (a file read keeps its newline; trim it)`;
72
+ }
73
+ return undefined;
74
+ };
75
+
76
+ /** The auth mount without slashes; `approle` when the caller did not say. */
77
+ export const loginMount = (mount: string | undefined): string => trimSlashes(mount ?? 'approle');
78
+
79
+ /**
80
+ * Why `mount` cannot name an auth mount, or undefined when it can.
81
+ *
82
+ * ⛔ THE MOUNT IS SPLICED INTO A URL, AND THE URL PARSER RESOLVES `..` AND ENDS THE PATH AT `?` OR
83
+ * `#`. `new URL('…/v1/auth/../sys/x/login')` is `…/v1/sys/x/login` — the role_id and secret_id
84
+ * would be sent to a path the caller never named. Refused rather than escaped, like padding.
85
+ */
86
+ export const mountProblem = (mount: string | undefined): string | undefined => {
87
+ const path = loginMount(mount);
88
+ if (path === '') return 'mount is empty';
89
+ const segments = path.split('/');
90
+ if (
91
+ /[\s?#%\\]/.test(path) ||
92
+ segments.some((part) => part === '' || part === '.' || part === '..')
93
+ ) {
94
+ return `mount ${JSON.stringify(path)} is not a plain mount path`;
95
+ }
96
+ return undefined;
97
+ };
98
+
99
+ /** `auth/<mount>/login` — what api/auth/approle/approle.go:145 writes. */
100
+ export const loginPath = (mount: string | undefined): string => `auth/${loginMount(mount)}/login`;
101
+
102
+ /** strconv.Quote's single-letter escapes, by code point. */
103
+ const GO_ESCAPES: Readonly<Record<number, string>> = {
104
+ 7: 'a',
105
+ 8: 'b',
106
+ 9: 't',
107
+ 10: 'n',
108
+ 11: 'v',
109
+ 12: 'f',
110
+ 13: 'r',
111
+ };
112
+
113
+ /**
114
+ * `value` as it appears between the quotes of Go's `%q` (strconv.Quote) — ASCII escapes only.
115
+ * ⚠️ Non-ASCII passes through as-is, which is what strconv.Quote does for PRINTABLE runes; a
116
+ * non-printable non-ASCII rune in a secret would escape differently and is not covered.
117
+ */
118
+ const goQuoted = (value: string): string =>
119
+ [...value]
120
+ .map((char) => {
121
+ const code = char.codePointAt(0) ?? 0;
122
+ const named = GO_ESCAPES[code];
123
+ if (char === '"' || char === '\\') return `\\${char}`;
124
+ if (named !== undefined) return `\\${named}`;
125
+ if (code < 0x20 || code === 0x7f) return `\\x${code.toString(16).padStart(2, '0')}`;
126
+ return char;
127
+ })
128
+ .join('');
129
+
130
+ /**
131
+ * Every string in `errors` with each credential replaced by `[redacted]`.
132
+ *
133
+ * ⛔ OPENBAO CAN ECHO THE SECRET_ID BACK, SO THIS IS NOT DEFENCE IN DEPTH — IT IS THE DEFENCE.
134
+ * openbao v2.6.2 builtin/credential/approle/path_login.go:291 answers a limited-use secret_id
135
+ * whose storage entry vanished between two reads with `invalid secret_id "<the secret_id>"`.
136
+ * BaoError keeps OpenBao's `errors` verbatim (that is its job), so every string that leaves the
137
+ * login or revoke passes through here first.
138
+ * ⚠️ THAT ECHO IS `%q`, SO THE RAW VALUE IS NOT ENOUGH. A custom secret_id holding `"` or `\` comes
139
+ * back escaped (`\"`, `\\`) and would slip past a raw match; the Go-quoted form is redacted too.
140
+ * ⚠️ LONGEST FIRST. Were one value a substring of another, redacting the shorter first would break
141
+ * the longer one's match and leave the rest of it in the message.
142
+ */
143
+ export const redact = (
144
+ errors: readonly string[],
145
+ secrets: readonly string[],
146
+ ): readonly string[] => {
147
+ const forms = [...new Set(secrets.flatMap((secret) => [secret, goQuoted(secret)]))]
148
+ .filter((form) => form !== '')
149
+ .sort((a, b) => b.length - a.length);
150
+ return errors.map((entry) =>
151
+ forms.reduce((text, form) => text.split(form).join('[redacted]'), entry),
152
+ );
153
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What a successful AppRole login hands back, and how it is read out of a 2xx body. Split from
3
+ * approle-login-form.ts, which decides what goes IN; this decides what comes OUT and how the token
4
+ * stays out of every log line that prints the result.
5
+ *
6
+ * ★ READ FROM openbao v2.6.2 api/secret.go:304-318 SecretAuth — `client_token`, `accessor`,
7
+ * `policies`, `lease_duration` and `mfa_requirement`. (api/*.go cited here diffed identical
8
+ * between v2.6.0 and v2.6.2.)
9
+ */
10
+
11
+ /** What a successful login hands back. */
12
+ export interface AppRoleLogin {
13
+ /**
14
+ * The token. ⛔ A GETTER OVER A PRIVATE FIELD, NEVER AN OWN PROPERTY — see LoginResult below.
15
+ * `console.log(login)` prints `clientToken: [Getter]` at most, and `JSON.stringify(login)`,
16
+ * `{ ...login }` and `structuredClone(login)` carry no token at all. Read it by name.
17
+ */
18
+ readonly clientToken: string;
19
+ /** The token's accessor — safe to log, and what revoke-accessor and audit lines name. */
20
+ readonly accessor: string;
21
+ /** Every policy on the token: the role's token_policies plus identity policies. */
22
+ readonly policies: readonly string[];
23
+ /** The token TTL in seconds, from the role's `token_ttl`. */
24
+ readonly leaseDurationSeconds: number;
25
+ }
26
+
27
+ /**
28
+ * ⛔ A PRIVATE FIELD BEHIND A PROTOTYPE GETTER, BECAUSE A NON-ENUMERABLE PROPERTY DID NOT HIDE THE
29
+ * TOKEN UNDER BUN. MEASURED 2026-09-21 on bun 1.4.0: `console.log` of an object whose token was a
30
+ * non-enumerable OWN property printed `clientToken: "<the token>"` in full — node's util.inspect
31
+ * hid it, bun's formatter did not — and this helper exists for Bun scripts. The first version
32
+ * shipped exactly that shape, with a test that checked JSON and Object.keys but never a print.
33
+ * This shape prints `clientToken: [Getter]` under bun's console.log and Bun.inspect, and nothing
34
+ * at all under node's default inspect.
35
+ * ⚠️ `util.inspect(login, { getters: true })` DOES evaluate the getter. That is an explicit request
36
+ * for the value, not an accident of logging the result.
37
+ */
38
+ class LoginResult implements AppRoleLogin {
39
+ readonly #token: string;
40
+ readonly accessor: string;
41
+ readonly policies: readonly string[];
42
+ readonly leaseDurationSeconds: number;
43
+
44
+ constructor(token: string, accessor: string, policies: readonly string[], lease: number) {
45
+ this.#token = token;
46
+ this.accessor = accessor;
47
+ this.policies = Object.freeze([...policies]);
48
+ this.leaseDurationSeconds = lease;
49
+ Object.freeze(this);
50
+ }
51
+
52
+ get clientToken(): string {
53
+ return this.#token;
54
+ }
55
+ }
56
+
57
+ const strings = (value: unknown): readonly string[] =>
58
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
59
+
60
+ /**
61
+ * The login in a 2xx body, or the reason there is none.
62
+ *
63
+ * ⚠️ AN EMPTY `client_token` WITH A `mfa_requirement` IS AN UNFINISHED LOGIN, NOT A BROKEN ONE. The
64
+ * auth block carries that field for a login that still owes an MFA step (api/secret.go:317). A
65
+ * script cannot complete that step, so it is named rather than reported as a missing field.
66
+ * REASONED FROM THE CLIENT TYPE — no MFA-enforced login was run.
67
+ * ⛔ THE BODY IS NEVER QUOTED. It holds the token on success and is not OpenBao's on a wrong address.
68
+ */
69
+ export const loginOf = (
70
+ body: Record<string, unknown> | undefined,
71
+ ): { readonly login: AppRoleLogin } | { readonly problem: string } => {
72
+ const auth = body?.['auth'];
73
+ if (typeof auth !== 'object' || auth === null || Array.isArray(auth)) {
74
+ return { problem: 'the answer carried no auth block' };
75
+ }
76
+ const fields = auth as Record<string, unknown>;
77
+ const token = fields['client_token'];
78
+ if (typeof token !== 'string' || token === '') {
79
+ const mfa = fields['mfa_requirement'];
80
+ return {
81
+ problem:
82
+ mfa !== undefined && mfa !== null
83
+ ? 'the login requires MFA, which this helper does not complete'
84
+ : 'the auth block carried no client_token',
85
+ };
86
+ }
87
+ const lease = fields['lease_duration'];
88
+ return {
89
+ login: new LoginResult(
90
+ token,
91
+ typeof fields['accessor'] === 'string' ? fields['accessor'] : '',
92
+ strings(fields['policies']),
93
+ typeof lease === 'number' && Number.isFinite(lease) ? lease : 0,
94
+ ),
95
+ };
96
+ };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * appRoleLogin / revokeSelf against a fake, through the PROMISE wrappers a plain script calls — so
3
+ * the fetch client, the env override and the error type are all exercised, not just the Effect.
4
+ *
5
+ * ⛔ Every call passes an explicit `env`; nothing here reads the shell's BAO_ADDR or BAO_TOKEN.
6
+ */
7
+ import assert from 'node:assert/strict';
8
+ import { describe, it } from 'node:test';
9
+ import { inspect } from 'node:util';
10
+ import { BaoLoginError, appRoleLogin, revokeSelf } from './approle-login.ts';
11
+ import { withFake } from './fake-bao.ts';
12
+
13
+ const ROLE = 'role-id-fixture';
14
+ const SECRET = 'fixturefixture';
15
+ const AUTH = {
16
+ auth: {
17
+ accessor: 'accessor-fixture',
18
+ client_token: 'token-fixture',
19
+ lease_duration: 900,
20
+ policies: ['default', 'host-cert'],
21
+ },
22
+ };
23
+
24
+ const failure = async (pending: Promise<unknown>) => {
25
+ const error = await pending.then(
26
+ () => assert.fail('expected a rejection'),
27
+ (caught: unknown) => caught,
28
+ );
29
+ assert.ok(error instanceof BaoLoginError, 'rejects with BaoLoginError itself');
30
+ return error;
31
+ };
32
+
33
+ describe('appRoleLogin', () => {
34
+ it('logs in with no token header, whatever the environment holds', async () => {
35
+ await withFake(
36
+ () => ({ json: AUTH, status: 200 }),
37
+ async (bao) => {
38
+ const env = {
39
+ BAO_ADDR: bao.address,
40
+ BAO_NAMESPACE: 'example-ns',
41
+ BAO_TOKEN: 'operator-token',
42
+ VAULT_TOKEN: 'legacy-token',
43
+ };
44
+ const login = await appRoleLogin({ env, roleId: ROLE, secretId: SECRET });
45
+ const [seen] = bao.seen;
46
+ assert.equal(seen?.method, 'PUT');
47
+ assert.equal(seen?.path, '/v1/auth/approle/login');
48
+ assert.equal(seen?.body, JSON.stringify({ role_id: ROLE, secret_id: SECRET }));
49
+ assert.equal(seen?.headers.get('x-vault-token'), null);
50
+ assert.equal(seen?.headers.get('x-vault-namespace'), 'example-ns');
51
+ assert.equal(login.clientToken, 'token-fixture');
52
+ assert.equal(login.accessor, 'accessor-fixture');
53
+ assert.deepEqual(login.policies, ['default', 'host-cert']);
54
+ assert.equal(login.leaseDurationSeconds, 900);
55
+ },
56
+ );
57
+ });
58
+
59
+ /**
60
+ * ⚠️ THE PRINTS ARE THE POINT. The first version passed JSON and Object.keys checks and still
61
+ * printed the token in full under bun's console.log (approle-login-result.ts has the measure).
62
+ */
63
+ it('keeps the token out of every print, JSON, spread and clone', async () => {
64
+ await withFake(
65
+ () => ({ json: AUTH, status: 200 }),
66
+ async (bao) => {
67
+ const env = { BAO_ADDR: bao.address };
68
+ const login = await appRoleLogin({ env, roleId: ROLE, secretId: SECRET });
69
+ const renderings = [
70
+ Bun.inspect(login),
71
+ inspect(login),
72
+ inspect(login, { showHidden: true }),
73
+ JSON.stringify(login),
74
+ JSON.stringify({ ...login }),
75
+ JSON.stringify(structuredClone(login)),
76
+ ];
77
+ for (const text of renderings) assert.ok(!text.includes('token-fixture'), text);
78
+ assert.equal(Object.keys(login).includes('clientToken'), false);
79
+ assert.equal(login.accessor, 'accessor-fixture');
80
+ },
81
+ );
82
+ });
83
+
84
+ it('logs in on a named mount', async () => {
85
+ await withFake(
86
+ () => ({ json: AUTH, status: 200 }),
87
+ async (bao) => {
88
+ const env = { BAO_ADDR: bao.address };
89
+ await appRoleLogin({ env, mount: '/approle-hosts/', roleId: ROLE, secretId: SECRET });
90
+ assert.equal(bao.seen[0]?.path, '/v1/auth/approle-hosts/login');
91
+ },
92
+ );
93
+ });
94
+
95
+ it('refuses empty, missing and padded inputs without sending anything', async () => {
96
+ await withFake(
97
+ () => ({ json: AUTH, status: 200 }),
98
+ async (bao) => {
99
+ const env = { BAO_ADDR: bao.address };
100
+ const cases = [
101
+ { roleId: '', secretId: SECRET },
102
+ { roleId: ROLE, secretId: `${SECRET}\n` },
103
+ { roleId: ROLE, secretId: undefined as unknown as string },
104
+ { mount: '/', roleId: ROLE, secretId: SECRET },
105
+ // ⛔ The URL parser would resolve these onto another path; nothing may be sent.
106
+ { mount: '../../sys/tools', roleId: ROLE, secretId: SECRET },
107
+ { mount: 'approle?x=', roleId: ROLE, secretId: SECRET },
108
+ ];
109
+ for (const input of cases) {
110
+ const error = await failure(appRoleLogin({ env, ...input }));
111
+ assert.equal(error.reason, 'input');
112
+ assert.ok(!error.message.includes(SECRET));
113
+ }
114
+ assert.equal(bao.seen.length, 0);
115
+ },
116
+ );
117
+ });
118
+
119
+ it('redacts a secret_id OpenBao echoes back (approle path_login.go:291)', async () => {
120
+ const echoed = { errors: [`invalid secret_id "${SECRET}"`] };
121
+ await withFake(
122
+ () => ({ json: echoed, status: 400 }),
123
+ async (bao) => {
124
+ const env = { BAO_ADDR: bao.address };
125
+ const error = await failure(appRoleLogin({ env, roleId: ROLE, secretId: SECRET }));
126
+ assert.equal(error.reason, 'refused');
127
+ assert.equal(error.status, 400);
128
+ assert.deepEqual(error.errors, ['invalid secret_id "[redacted]"']);
129
+ assert.ok(!error.message.includes(SECRET));
130
+ },
131
+ );
132
+ });
133
+
134
+ it('describes a non-JSON error page by size, never by content', async () => {
135
+ const page = `<html>502 upstream said ${SECRET}</html>`;
136
+ await withFake(
137
+ () => ({ status: 502, text: page }),
138
+ async (bao) => {
139
+ const env = { BAO_ADDR: bao.address };
140
+ const error = await failure(appRoleLogin({ env, roleId: ROLE, secretId: SECRET }));
141
+ assert.equal(error.status, 502);
142
+ assert.match(error.message, /not an OpenBao error body/);
143
+ assert.ok(!error.message.includes('upstream'));
144
+ },
145
+ );
146
+ });
147
+
148
+ it('names a 2xx without a usable login — not JSON, no auth block, unfinished MFA', async () => {
149
+ const replies = [
150
+ { json: { data: {} }, status: 200 },
151
+ {
152
+ json: { auth: { client_token: '', mfa_requirement: { mfa_request_id: 'x' } } },
153
+ status: 200,
154
+ },
155
+ // ⚠️ Some other web server at BAO_ADDR: a yes that is not a login, never `refused 200`.
156
+ { status: 200, text: '<html>welcome</html>' },
157
+ ];
158
+ const expected = [/no auth block/, /requires MFA/, /response 200: a success body/];
159
+ for (const [index, reply] of replies.entries()) {
160
+ await withFake(
161
+ () => reply,
162
+ async (bao) => {
163
+ const env = { BAO_ADDR: bao.address };
164
+ const error = await failure(appRoleLogin({ env, roleId: ROLE, secretId: SECRET }));
165
+ assert.equal(error.reason, 'response');
166
+ assert.match(error.message, expected[index] ?? /never/);
167
+ },
168
+ );
169
+ }
170
+ });
171
+ });
172
+
173
+ describe('revokeSelf', () => {
174
+ it('revokes the token it was given, not the environment one', async () => {
175
+ await withFake(
176
+ () => ({ status: 204 }),
177
+ async (bao) => {
178
+ await revokeSelf('token-fixture', { BAO_ADDR: bao.address, BAO_TOKEN: 'operator-token' });
179
+ const [seen] = bao.seen;
180
+ assert.equal(seen?.method, 'PUT');
181
+ assert.equal(seen?.path, '/v1/auth/token/revoke-self');
182
+ assert.equal(seen?.headers.get('x-vault-token'), 'token-fixture');
183
+ },
184
+ );
185
+ });
186
+
187
+ it('refuses an empty token and redacts a refused one', async () => {
188
+ await withFake(
189
+ () => ({ json: { errors: ['permission denied for token-fixture'] }, status: 403 }),
190
+ async (bao) => {
191
+ const env = { BAO_ADDR: bao.address };
192
+ assert.equal((await failure(revokeSelf('', env))).reason, 'input');
193
+ assert.equal(bao.seen.length, 0);
194
+ const refused = await failure(revokeSelf('token-fixture', env));
195
+ assert.equal(refused.reason, 'refused');
196
+ assert.ok(!refused.message.includes('token-fixture'));
197
+ },
198
+ );
199
+ });
200
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * AppRole login and self-revoke for a plain Bun script — the one part of the openbao subpath that is
3
+ * not a Resource.
4
+ *
5
+ * ★ WHY IT LIVES HERE. Wrapper scripts log in with a per-host AppRole and revoke the token on exit.
6
+ * Each one hand-rolling a `fetch` is how two of them end up disagreeing about BAO_ADDR versus
7
+ * BAO_AGENT_ADDR, or dropping the namespace header. This rides the transport every Bao.* family
8
+ * already uses: bao-address.ts resolution (BAO_ADDR, BAO_AGENT_ADDR, BAO_NAMESPACE, the VAULT_*
9
+ * twins, unix sockets), bao-http.ts's in-flight gate, timeout, transport retry and token
10
+ * redaction on spans, and bao-status.ts's status-first classification of the answer.
11
+ * ★ ONE IMPLEMENTATION, TWO SHAPES. `appRoleLoginEffect` and `revokeSelfEffect` are the
12
+ * implementation; `appRoleLogin` and `revokeSelf` run them on the fetch client for a caller with
13
+ * no Effect runtime. Both fail with BaoLoginError and nothing else.
14
+ *
15
+ * ⚠️ SHARING A secret_id ACROSS HOSTS THROUGH A CACHING PROXY SHARES THE TOKEN. Two hosts sending
16
+ * the same AppRole login through a caching OpenBao Proxy get the SAME cached token back, so two
17
+ * hosts become one identity in every audit line (recorded as a trap in the 2026-09-21 vault
18
+ * consolidation plan; not re-measured here). Give each host its own secret_id.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
22
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
23
+ import type { BaoEnvironment } from './bao-address.ts';
24
+ import { BaoEnv, baoCall } from './bao-http.ts';
25
+ import type { BaoError } from './bao-status.ts';
26
+ import {
27
+ BaoLoginError,
28
+ failureOf,
29
+ loginPath,
30
+ mountProblem,
31
+ redact,
32
+ refusal,
33
+ } from './approle-login-form.ts';
34
+ import { type AppRoleLogin, loginOf } from './approle-login-result.ts';
35
+
36
+ export type { BaoLoginFailure } from './approle-login-form.ts';
37
+ export type { AppRoleLogin } from './approle-login-result.ts';
38
+ export { BaoLoginError };
39
+
40
+ export interface AppRoleLoginInput {
41
+ /** The role_id. Not a secret on its own, but half of one — it is redacted from errors too. */
42
+ readonly roleId: string;
43
+ /** ⛔ The secret_id. Never logged, never in an error — see `redact`. */
44
+ readonly secretId: string;
45
+ /** The AppRole auth mount, e.g. `approle` (the default) — no `.`/`..` segments, `?` or `#`. */
46
+ readonly mount?: string;
47
+ }
48
+
49
+ const LOGIN = 'OpenBao AppRole login';
50
+ const REVOKE = 'OpenBao revoke-self';
51
+
52
+ const failed =
53
+ (operation: string, secrets: readonly string[]) =>
54
+ (error: BaoError): BaoLoginError =>
55
+ new BaoLoginError(
56
+ failureOf(error.status),
57
+ error.status,
58
+ redact(error.errors, secrets),
59
+ operation,
60
+ );
61
+
62
+ /**
63
+ * Log in with a role_id and secret_id, under BaoEnv (process.env unless a caller provides one).
64
+ *
65
+ * ⛔ THE LOGIN SENDS NO TOKEN, WHATEVER THE ENVIRONMENT HOLDS. BAO_TOKEN is overridden with a
66
+ * PRESENT-BUT-EMPTY value for this one call, which bao-address.ts readBaoVariable treats as
67
+ * winning over VAULT_TOKEN, so headersFor omits X-Vault-Token entirely. A script that already
68
+ * had an operator token exported must not send it along with a machine login.
69
+ * ⚠️ A DROPPED TRANSPORT IS RETRIED TWICE (bao-http.ts retryTransport, status 0 only). If the first
70
+ * request reached OpenBao and only the answer was lost, the retry is a second login: one extra
71
+ * token that lives until its TTL, and — for a secret_id with `secret_id_num_uses` — one extra use.
72
+ * A single-use secret_id can therefore come back "invalid" on a flaky path.
73
+ * ★ `PUT`, THE METHOD api/logical.go:325 WriteWithContext SENDS, which is what the official Go helper
74
+ * calls (api/auth/approle/approle.go:146). The docs show POST; both are the update operation.
75
+ */
76
+ export const appRoleLoginEffect = (
77
+ input: AppRoleLoginInput,
78
+ ): Effect.Effect<AppRoleLogin, BaoLoginError, HttpClient.HttpClient> =>
79
+ Effect.gen(function* () {
80
+ const problems = [
81
+ refusal('roleId', input.roleId),
82
+ refusal('secretId', input.secretId),
83
+ mountProblem(input.mount),
84
+ ].filter((problem): problem is string => problem !== undefined);
85
+ if (problems.length > 0)
86
+ return yield* Effect.fail(new BaoLoginError('input', 0, problems, LOGIN));
87
+ const env = yield* BaoEnv;
88
+ const body = yield* baoCall('write', 'PUT', loginPath(input.mount), {
89
+ role_id: input.roleId,
90
+ secret_id: input.secretId,
91
+ }).pipe(
92
+ Effect.provideService(BaoEnv, { ...env, BAO_TOKEN: '' }),
93
+ Effect.mapError(failed(LOGIN, [input.roleId, input.secretId])),
94
+ );
95
+ const read = loginOf(body);
96
+ if ('problem' in read) {
97
+ return yield* Effect.fail(new BaoLoginError('response', 0, [read.problem], LOGIN));
98
+ }
99
+ return read.login;
100
+ });
101
+
102
+ /**
103
+ * Revoke `token` itself — what a wrapper runs on exit, so a login does not outlive its job.
104
+ *
105
+ * ★ `PUT auth/token/revoke-self`, READ FROM openbao v2.6.2 api/auth_token.go:318-330: the token
106
+ * revoked is the one in X-Vault-Token, and the body is empty. `token` rides in that header for
107
+ * this call only (BAO_TOKEN is overridden), whatever the environment holds.
108
+ * ⚠️ AN ALREADY-REVOKED OR EXPIRED TOKEN IS A FAILURE, NOT A NO-OP. OpenBao refuses a token it no
109
+ * longer knows (permission denied — not measured here), and a refusal from the wrong server looks
110
+ * the same; "I could not tell" stays an error. A wrapper that only wants best-effort cleanup on
111
+ * exit can catch BaoLoginError with reason `refused`.
112
+ * ⚠️ NEVER THROUGH AN AGENT OR PROXY IN `use_auto_auth_token = "force"` MODE. Force overwrites the
113
+ * request's token with the agent's own (bao-address.ts headersFor cites apiproxy.mdx), so this
114
+ * would revoke the AGENT's token. The estate's LAN proxy runs with no auto-auth at all.
115
+ */
116
+ export const revokeSelfEffect = (
117
+ token: string,
118
+ ): Effect.Effect<void, BaoLoginError, HttpClient.HttpClient> =>
119
+ Effect.gen(function* () {
120
+ const problem = refusal('token', token);
121
+ if (problem !== undefined) {
122
+ return yield* Effect.fail(new BaoLoginError('input', 0, [problem], REVOKE));
123
+ }
124
+ const env = yield* BaoEnv;
125
+ yield* baoCall('write', 'PUT', 'auth/token/revoke-self').pipe(
126
+ Effect.provideService(BaoEnv, { ...env, BAO_TOKEN: token }),
127
+ Effect.mapError(failed(REVOKE, [token])),
128
+ );
129
+ });
130
+
131
+ /**
132
+ * ★ `Effect.runPromise` REJECTS WITH THE FAILURE ITSELF on this Effect line (measured on
133
+ * effect 4.0.0-rc.115: the rejection is `instanceof` the failed class), so a script catches
134
+ * BaoLoginError directly.
135
+ */
136
+ const runWith = <A>(
137
+ env: BaoEnvironment | undefined,
138
+ effect: Effect.Effect<A, BaoLoginError, HttpClient.HttpClient>,
139
+ ): Promise<A> => {
140
+ const scoped = env === undefined ? effect : Effect.provideService(effect, BaoEnv, env);
141
+ return Effect.runPromise(scoped.pipe(Effect.provide(FetchHttpClient.layer)));
142
+ };
143
+
144
+ /**
145
+ * `appRoleLoginEffect` for a plain script. `env` defaults to `process.env`, read at call time.
146
+ *
147
+ * const login = await appRoleLogin({ roleId, secretId });
148
+ * try { … login.clientToken … } finally { await revokeSelf(login.clientToken); }
149
+ */
150
+ export const appRoleLogin = (
151
+ input: AppRoleLoginInput & { readonly env?: BaoEnvironment },
152
+ ): Promise<AppRoleLogin> => runWith(input.env, appRoleLoginEffect(input));
153
+
154
+ /** `revokeSelfEffect` for a plain script. `env` defaults to `process.env`, read at call time. */
155
+ export const revokeSelf = (token: string, env?: BaoEnvironment): Promise<void> =>
156
+ runWith(env, revokeSelfEffect(token));