@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,42 @@
1
+ /**
2
+ * Bao.AuthRole's secret_id TTL at the 90-day length the per-host AppRoles use.
3
+ *
4
+ * ★ `2160h` IS WHAT GOES ON THE WIRE, AND OPENBAO ACCEPTS IT. MEASURED 2026-09-21 with a Go probe of
5
+ * sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
6
+ * TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
7
+ * read hands back that integer, which attributesOf renders as `90d` — the same role.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { describe, it } from 'node:test';
11
+ import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
12
+
13
+ const PROPS: BaoAuthRoleProps = {
14
+ bindSecretId: true,
15
+ name: 'host-cert',
16
+ secretIdNumUses: 0,
17
+ secretIdTtl: '2160h',
18
+ tokenMaxTtl: '1h',
19
+ tokenPolicies: ['host-cert', 'default'],
20
+ tokenTtl: '15m',
21
+ };
22
+
23
+ describe('auth role secret_id TTL', () => {
24
+ it('writes 2160h as given', () => {
25
+ assert.equal(writeBody(PROPS)['secret_id_ttl'], '2160h');
26
+ });
27
+
28
+ it('reads 7776000 seconds back as the same role', () => {
29
+ const live = {
30
+ bind_secret_id: true,
31
+ secret_id_num_uses: 0,
32
+ secret_id_ttl: 7776000,
33
+ token_max_ttl: 3600,
34
+ token_policies: ['default', 'host-cert'],
35
+ token_ttl: 900,
36
+ };
37
+ const attributes = attributesOf(PROPS, live);
38
+ assert.equal(attributes.secretIdTtl, '90d');
39
+ assert.equal(matches(attributes, PROPS), true);
40
+ assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
41
+ });
42
+ });
@@ -190,6 +190,8 @@ export const baoRead = (path: string) =>
190
190
  * ⚠️ ROLE BODIES STAY ALL-STRING ON PURPOSE. `bao write k=v` put every value in the JSON body as a
191
191
  * string — MEASURED with `-output-curl-string`, see ssh-role-form.ts — and the form files still
192
192
  * build exactly that body, so the server parses the same request it was always sent.
193
+ * ⛔ EXCEPT A MAP FIELD: sdk v2.6.2 refuses a string for a TypeMap with a 400, so Bao.SshRole
194
+ * sends its two maps as objects (the ⛔ on mapValue in ssh-role-form.ts, measured 2026-09-21).
193
195
  */
194
196
  export const baoWrite = (
195
197
  method: 'POST' | 'PUT',
@@ -7,7 +7,18 @@
7
7
  * every helper as API and make the next refactor a breaking change.
8
8
  * ★ Anything unlisted is still reachable by path if you genuinely need it — that is a
9
9
  * deliberate, visible act rather than an accident of barrelling.
10
+ * ★ THE APPROLE LOGIN IS HERE FOR SCRIPTS, NOT STACKS (approle-login.ts). A wrapper that logs in
11
+ * and revokes on exit needs it, and giving it the families' own transport is what keeps a script
12
+ * and a stack from disagreeing about which server BAO_ADDR means.
10
13
  */
14
+ export type { AppRoleLogin, AppRoleLoginInput, BaoLoginFailure } from './approle-login.ts';
15
+ export {
16
+ BaoLoginError,
17
+ appRoleLogin,
18
+ appRoleLoginEffect,
19
+ revokeSelf,
20
+ revokeSelfEffect,
21
+ } from './approle-login.ts';
11
22
  export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
12
23
  export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
13
24
  export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
@@ -22,6 +33,8 @@ export type { RolesConfig, SurfaceSpec } from './cloudflare-roles-config.ts';
22
33
  export { parseRolesConfig } from './cloudflare-roles-config.ts';
23
34
  export { BaoMount, BaoMountProvider } from './mount.ts';
24
35
  export { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
36
+ export type { BaoPluginProps, BaoPluginType } from './plugin.ts';
37
+ export { BaoPlugin, BaoPluginProvider } from './plugin.ts';
25
38
  export { BaoPolicy, BaoPolicyProvider } from './policy.ts';
26
39
  export { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
27
40
  export { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Slash trimming stays linear on caller input (CodeQL js/polynomial-redos).
3
+ */
4
+ import assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+ import { trimSlashes, trimTrailingSlashes } from './mount-path.ts';
7
+
8
+ describe('mount-path', () => {
9
+ it('trims outer slashes and keeps inner ones', () => {
10
+ assert.equal(trimSlashes('/team/approle/'), 'team/approle');
11
+ assert.equal(trimSlashes('///'), '');
12
+ assert.equal(trimTrailingSlashes('/ssh-host//'), '/ssh-host');
13
+ assert.equal(trimTrailingSlashes('ssh'), 'ssh');
14
+ });
15
+
16
+ it('handles a long run of slashes before other text in linear time', () => {
17
+ const run = '/'.repeat(200_000);
18
+ const started = performance.now();
19
+ assert.equal(trimTrailingSlashes(`${run}x`), `${run}x`);
20
+ assert.equal(trimSlashes(`${run}x${run}`), 'x');
21
+ assert.ok(performance.now() - started < 1_000, 'slash trimming took over a second');
22
+ });
23
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Slash trimming for caller-supplied mount paths.
3
+ *
4
+ * ★ LOOPS, NOT `/^\/+|\/+$/g` OR `/\/+$/`. A trailing `\/+$` backtracks polynomially on a long run
5
+ * of `/` followed by anything else (CodeQL js/polynomial-redos), and mount paths are caller input.
6
+ * Both loops are linear in the input length.
7
+ */
8
+
9
+ /** `text` without trailing `/`. */
10
+ export const trimTrailingSlashes = (text: string): string => {
11
+ let end = text.length;
12
+ while (end > 0 && text[end - 1] === '/') end -= 1;
13
+ return text.slice(0, end);
14
+ };
15
+
16
+ /** `text` without leading or trailing `/`. */
17
+ export const trimSlashes = (text: string): string => {
18
+ let start = 0;
19
+ while (start < text.length && text[start] === '/') start += 1;
20
+ return trimTrailingSlashes(text.slice(start));
21
+ };
22
+
23
+ /** SSH mount path without a trailing slash; `ssh` when the caller did not say. */
24
+ export const sshMountOf = (mount: string | undefined): string =>
25
+ trimTrailingSlashes(mount ?? 'ssh');
@@ -93,6 +93,19 @@ describe('enable, tune, disable', () => {
93
93
  );
94
94
  });
95
95
 
96
+ it('enables an ssh engine at any path — type is a free string, sent as given', async () => {
97
+ await withFake(
98
+ () => ({ status: 204 }),
99
+ async (bao) => {
100
+ await run({ BAO_ADDR: bao.address }, enableMount({ path: 'ssh-host/', type: 'ssh' }));
101
+ assert.deepEqual(
102
+ bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
103
+ [['POST', '/v1/sys/mounts/ssh-host', JSON.stringify({ type: 'ssh' })]],
104
+ );
105
+ },
106
+ );
107
+ });
108
+
96
109
  it('fails a refused enable with OpenBao errors', async () => {
97
110
  await withFake(
98
111
  () => ({ json: { errors: ['path is already in use at kv/'] }, status: 400 }),
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Bao.Plugin's form: what a declaration may say, the body it registers with, and the equality a
3
+ * plan's `noop` rests on. The shapes are the v2.6.2 handler's (plugin-form.ts cites the lines).
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import {
8
+ type BaoPluginProps,
9
+ attributesOf,
10
+ matches,
11
+ problems,
12
+ registerBody,
13
+ resolve,
14
+ versionedPath,
15
+ } from './plugin-form.ts';
16
+
17
+ const SUM = '0123456789abcdef'.repeat(4);
18
+ const UNVERSIONED: BaoPluginProps = {
19
+ command: 'openbao-plugin-secrets-cloudflare',
20
+ name: 'openbao-plugin-secrets-cloudflare',
21
+ sha256: SUM.toUpperCase(),
22
+ type: 'secret',
23
+ };
24
+ const PROPS: BaoPluginProps = { ...UNVERSIONED, version: 'v0.1.2' };
25
+
26
+ describe('plugin form', () => {
27
+ it('resolves the defaults the register call stores, and lower-cases the sum', () => {
28
+ const form = resolve(UNVERSIONED);
29
+ assert.equal(form.sha256, SUM);
30
+ assert.deepEqual(form.args, []);
31
+ assert.equal(form.version, '');
32
+ assert.deepEqual(problems(resolve(PROPS)), []);
33
+ });
34
+
35
+ it('refuses what the server would reject or store differently', () => {
36
+ const bad = (patch: Partial<BaoPluginProps>) => problems(resolve({ ...PROPS, ...patch }));
37
+ for (const name of ['a/b', '..x', '', 'has space']) assert.equal(bad({ name }).length, 1);
38
+ assert.equal(bad({ sha256: 'abc' }).length, 1);
39
+ for (const command of ['plugin --flag', 'sub/plugin', '/abs/plugin', '../plugin', './plugin']) {
40
+ assert.equal(bad({ command }).length, 1, command);
41
+ }
42
+ // ⚠️ Accepted by the server but stored canonicalised, so they would never read back equal.
43
+ for (const version of ['1.2.0', 'v1.2', 'v01.2.0']) assert.equal(bad({ version }).length, 1);
44
+ assert.ok(bad({ version: 'v1.2.0+builtin' }).length > 0);
45
+ assert.deepEqual(bad({ version: 'v1.2.0-rc.1' }), []);
46
+ });
47
+
48
+ it('reads and deletes one version, and registers at the unversioned path', () => {
49
+ assert.equal(
50
+ versionedPath('secret', 'x', 'v1.0.0+meta'),
51
+ 'sys/plugins/catalog/secret/x?version=v1.0.0%2Bmeta',
52
+ );
53
+ assert.equal(versionedPath('auth', 'x', ''), 'sys/plugins/catalog/auth/x');
54
+ });
55
+
56
+ it('registers with the RegisterPluginInput fields and never env or a numeric type', () => {
57
+ assert.deepEqual(registerBody(resolve(PROPS)), {
58
+ command: PROPS.command,
59
+ sha256: SUM,
60
+ version: 'v0.1.2',
61
+ });
62
+ const withArgs = registerBody(resolve({ ...UNVERSIONED, args: ['--b', '--a'] }));
63
+ assert.deepEqual(withArgs, { args: ['--b', '--a'], command: PROPS.command, sha256: SUM });
64
+ assert.equal('env' in withArgs || 'type' in withArgs, false);
65
+ });
66
+
67
+ it('matches the live entry by value, args in order, and never a builtin', () => {
68
+ const form = resolve({ ...PROPS, args: ['--a', '--b'] });
69
+ const live = {
70
+ args: ['--a', '--b'],
71
+ builtin: false,
72
+ command: PROPS.command,
73
+ declarative: false,
74
+ name: PROPS.name,
75
+ oci: false,
76
+ sha256: SUM,
77
+ version: 'v0.1.2',
78
+ };
79
+ assert.equal(matches(attributesOf(form, live), form), true);
80
+ assert.equal(matches(attributesOf(form, { ...live, args: ['--b', '--a'] }), form), false);
81
+ assert.equal(matches(attributesOf(form, { ...live, sha256: 'f'.repeat(64) }), form), false);
82
+ assert.equal(matches(attributesOf(form, { ...live, builtin: true }), form), false);
83
+ const bare = resolve(UNVERSIONED);
84
+ assert.deepEqual(attributesOf(bare, { ...live, args: null, version: '' }).args, []);
85
+ });
86
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The form half of Bao.Plugin: props, the paths, the register body, and the equality that decides
3
+ * whether a plan says `noop`. Split from plugin.ts the way mount-form.ts is.
4
+ *
5
+ * ★ THE CATALOG API, READ FROM openbao v2.6.2 — the docs AND the server, because they disagree:
6
+ * · docs/api/system/plugins-catalog.mdx — `POST sys/plugins/catalog/:type/:name` registers with
7
+ * `sha256`, `command`, `args`, `env`, `version`, `oci`; `GET` and `DELETE` take `?version=`, which
8
+ * is REQUIRED to read a plugin registered with one. All three need `sudo`.
9
+ * · vault/logical_system.go:515-576 handlePluginCatalogRead — answers `name`, `args`, `command`
10
+ * (relative to plugin_directory), `sha256`, `builtin`, `version`, `oci`, `declarative`. ⚠️ The
11
+ * docs' sample shows sha256 as BASE64; the server writes `hex.EncodeToString` (:561). Hex wins.
12
+ * · vault/logical_system.go:441-513 handlePluginCatalogUpdate, and :615-628 getVersion — the
13
+ * version is canonicalised to `"v" + semver.String()` before it is stored.
14
+ */
15
+ import { sha256 as digestOf } from './digest.ts';
16
+
17
+ export type BaoPluginType = 'auth' | 'database' | 'secret';
18
+
19
+ export interface BaoPluginProps {
20
+ /** Catalog name — what a mount's `type` names, e.g. `openbao-plugin-secrets-cloudflare`. */
21
+ name: string;
22
+ /** Catalog type. `secret` for a secrets engine, `auth` for an auth method. */
23
+ type: BaoPluginType;
24
+ /** Hex SHA-256 of the binary already sitting in plugin_directory. Case is normalised. */
25
+ sha256: string;
26
+ /** The binary's file name inside plugin_directory — a bare name, no path, no arguments. */
27
+ command: string;
28
+ /**
29
+ * Canonical semver with its `v`, e.g. `v0.1.2`. ⚠️ When the binary reports its own version,
30
+ * declare EXACTLY that one: any other is refused at write ("plugin version mismatch",
31
+ * plugin_catalog.go setInternal), and none at all is filed under the reported one — see the ⚠️
32
+ * on SELF_REPORTED in plugin-reconcile.ts.
33
+ */
34
+ version?: string;
35
+ /** Arguments, in order. ⛔ Never a secret: they land in Alchemy state and in every catalog read. */
36
+ args?: readonly string[];
37
+ }
38
+
39
+ /** ⛔ NO SECRET HERE — names, a hash, a file name, arguments and a digest. */
40
+ export interface BaoPluginAttributes {
41
+ name: string;
42
+ type: BaoPluginType;
43
+ /** The canonical version the catalog stored, `''` when unversioned. Delete needs it. */
44
+ version: string;
45
+ sha256: string;
46
+ command: string;
47
+ args: readonly string[];
48
+ /** True when the catalog answered with OpenBao's own builtin rather than a registration. */
49
+ builtin: boolean;
50
+ /** SHA-256 of the managed fields — safe to persist; see policy.ts. */
51
+ digest: string;
52
+ }
53
+
54
+ /** Every prop resolved to what the register call will store. */
55
+ export type BaoPluginForm = Required<BaoPluginProps>;
56
+
57
+ export const resolve = (props: BaoPluginProps): BaoPluginForm => ({
58
+ args: props.args ?? [],
59
+ command: props.command,
60
+ name: props.name,
61
+ sha256: props.sha256.toLowerCase(),
62
+ type: props.type,
63
+ version: props.version ?? '',
64
+ });
65
+
66
+ /**
67
+ * ⛔ THE VERSION MUST ALREADY BE CANONICAL, OR THE PLAN NEVER GOES GREEN. The server stores
68
+ * `"v" + semver.String()` (getVersion, :615-628), so `1.2.0`, `v1.2` and `v01.2.0` are all
69
+ * written as `v1.2.0`, read back as `v1.2.0`, and compared unequal to the declaration forever.
70
+ * Refusing anything but the canonical spelling is cheaper than reimplementing go-version.
71
+ */
72
+ const CANONICAL_VERSION =
73
+ /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
74
+
75
+ /** Everything wrong with the declaration, or `[]`. Checked before any request. */
76
+ export const problems = (form: BaoPluginForm): readonly string[] => {
77
+ const found: string[] = [];
78
+ // ⚠️ The route is `(?P<name>.+)` (logical_system_paths.go:1697), so a `/` would nest the storage
79
+ // key; the server refuses `..` in a name (plugin_catalog.go Set).
80
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(form.name) || form.name.includes('..')) {
81
+ found.push(`name ${JSON.stringify(form.name)} is not a plain catalog name`);
82
+ }
83
+ if (!/^[0-9a-f]{64}$/.test(form.sha256)) found.push('sha256 is not 64 hex characters');
84
+ /**
85
+ * ⛔ A BARE FILE NAME. The server splits `command` on spaces and keeps the rest as args
86
+ * (handlePluginCatalogUpdate :487-495), and refuses a binary whose directory is not
87
+ * plugin_directory itself (plugin_catalog.go setInternal, `symAbs != c.directory`) — so a
88
+ * path or an argument here either fails the write or reads back as something else.
89
+ */
90
+ if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(form.command) || form.command.includes('..')) {
91
+ found.push(`command ${JSON.stringify(form.command)} is not a bare file name`);
92
+ }
93
+ if (form.version !== '' && !CANONICAL_VERSION.test(form.version)) {
94
+ found.push(`version ${JSON.stringify(form.version)} is not canonical semver like v1.2.0`);
95
+ }
96
+ // ⚠️ `+builtin` metadata is reserved; the server refuses it (handlePluginCatalogUpdate :462-464).
97
+ if (/\+builtin/.test(form.version)) found.push('version metadata `builtin` is reserved');
98
+ return found;
99
+ };
100
+
101
+ /** `?version=` only when there is one — an unversioned plugin is read without it. */
102
+ const versionQuery = (version: string) =>
103
+ version === '' ? '' : `?version=${encodeURIComponent(version)}`;
104
+
105
+ /** `sys/plugins/catalog/<type>/<name>` — register addresses the plugin here. */
106
+ export const catalogPath = (type: BaoPluginType, name: string): string =>
107
+ `sys/plugins/catalog/${type}/${name}`;
108
+
109
+ /** Read and delete address one VERSION of the plugin. */
110
+ export const versionedPath = (type: BaoPluginType, name: string, version: string): string =>
111
+ `${catalogPath(type, name)}${versionQuery(version)}`;
112
+
113
+ /**
114
+ * The register body — the fields RegisterPluginInput sends (api v2.6.0 sys_plugins.go:186-204),
115
+ * which is what `bao plugin register` sends.
116
+ *
117
+ * ★ `type` IS LEFT OUT OF THE BODY ON PURPOSE. The Go client puts it there as a NUMBER (PluginType
118
+ * has no MarshalJSON), and the route's `type` capture is what the handler reads. The path says it.
119
+ * ⛔ NO `env`, EVER. The catalog read never returns env (handlePluginCatalogRead's data map has no
120
+ * such key), so a declared env could not be diffed, and what env exists to carry into a plugin
121
+ * process is exactly what must not sit in Alchemy's unencrypted state. ⚠️ The register call is a
122
+ * FULL REPLACE (setInternal builds a fresh entry), so an env someone set by hand is dropped by
123
+ * the next write — which only happens when a managed field has drifted.
124
+ */
125
+ export const registerBody = (form: BaoPluginForm): Record<string, unknown> => ({
126
+ command: form.command,
127
+ sha256: form.sha256,
128
+ ...(form.args.length > 0 ? { args: form.args } : {}),
129
+ ...(form.version === '' ? {} : { version: form.version }),
130
+ });
131
+
132
+ const text = (value: unknown) => (typeof value === 'string' ? value : '');
133
+
134
+ /** ⚠️ `args` is a Go slice, so an entry stored without any can read back `null` — the same as `[]`. */
135
+ const list = (value: unknown): readonly string[] =>
136
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
137
+
138
+ export const attributesOf = (
139
+ form: BaoPluginForm,
140
+ live: Record<string, unknown>,
141
+ ): BaoPluginAttributes => {
142
+ const attrs = {
143
+ name: form.name,
144
+ type: form.type,
145
+ version: text(live['version']),
146
+ sha256: text(live['sha256']).toLowerCase(),
147
+ command: text(live['command']),
148
+ args: list(live['args']),
149
+ builtin: live['builtin'] === true,
150
+ };
151
+ return { ...attrs, digest: digestOf(JSON.stringify(attrs)) };
152
+ };
153
+
154
+ /** ⚠️ ORDER MATTERS for args — they are an argv, not a set. */
155
+ const sameArgs = (want: readonly string[], have: readonly string[]) =>
156
+ want.length === have.length && want.every((entry, index) => entry === have[index]);
157
+
158
+ /** True when the catalog already holds exactly this registration. A builtin never matches. */
159
+ export const matches = (attributes: BaoPluginAttributes, form: BaoPluginForm): boolean =>
160
+ !attributes.builtin &&
161
+ attributes.version === form.version &&
162
+ attributes.sha256 === form.sha256 &&
163
+ attributes.command === form.command &&
164
+ sameArgs(form.args, attributes.args);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Bao.Plugin's read and reconcile, as plain effects — split from plugin.ts so the refusals below can
3
+ * be run against a fake server rather than only reasoned about.
4
+ */
5
+ import * as Effect from 'effect/Effect';
6
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
7
+ import type { BaoError } from './bao-status.ts';
8
+ import {
9
+ type BaoPluginAttributes,
10
+ type BaoPluginForm,
11
+ type BaoPluginProps,
12
+ attributesOf,
13
+ matches,
14
+ problems,
15
+ resolve,
16
+ versionedPath,
17
+ } from './plugin-form.ts';
18
+ import { readPluginData, registerPlugin } from './plugin-wire.ts';
19
+
20
+ type Entry = { readonly attributes: BaoPluginAttributes; readonly live: Record<string, unknown> };
21
+
22
+ /** Live entry, plus the raw body — reconcile needs the raw half for `oci` and `declarative`. */
23
+ export const readEntry = (
24
+ form: BaoPluginForm,
25
+ ): Effect.Effect<Entry | undefined, BaoError, HttpClient.HttpClient> =>
26
+ Effect.map(readPluginData(form), (live) =>
27
+ live === undefined ? undefined : { attributes: attributesOf(form, live), live },
28
+ );
29
+
30
+ const refuse = (form: BaoPluginForm, message: string) =>
31
+ Effect.die(
32
+ new Error(`Bao.Plugin ${versionedPath(form.type, form.name, form.version)}: ${message}`),
33
+ );
34
+
35
+ /**
36
+ * ⚠️ AN UNVERSIONED REGISTRATION OF A BINARY THAT REPORTS ITS OWN VERSION IS FILED UNDER THAT
37
+ * VERSION. setInternal (plugin_catalog.go:1222-1228) takes the self-reported version when the
38
+ * request names none, and the storage key carries it — so the unversioned read that follows finds
39
+ * nothing, or finds an OLDER genuinely-unversioned entry. The Cloudflare engine reports one
40
+ * (`framework.Backend.RunningVersion`). REASONED FROM SOURCE, not measured against a server.
41
+ * ⚠️ THE WRITE HAS ALREADY LANDED when this fires, so the catalog holds a registration no state
42
+ * record names. Declaring the reported version adopts it on the next deploy (same sha256 and
43
+ * command, so reconcile matches and writes nothing); declaring a DIFFERENT version is refused by
44
+ * the server itself (setInternal :1219-1221, "plugin version mismatch").
45
+ */
46
+ const SELF_REPORTED =
47
+ 'the write succeeded but the read-back does not match. If the binary reports its own version, ' +
48
+ 'OpenBao filed the registration under it (plugin_catalog.go setInternal) — declare `version` ' +
49
+ 'as exactly the version the binary reports; that adopts the entry this write just made.';
50
+
51
+ /** Register when the live entry differs, then prove it by reading back. Dies on a refusal. */
52
+ export const reconcilePlugin = (
53
+ props: BaoPluginProps,
54
+ ): Effect.Effect<BaoPluginAttributes, BaoError, HttpClient.HttpClient> =>
55
+ Effect.gen(function* () {
56
+ const form = resolve(props);
57
+ const bad = problems(form);
58
+ if (bad.length > 0) return yield* refuse(form, bad.join('; '));
59
+
60
+ const found = yield* readEntry(form);
61
+ if (found !== undefined && !matches(found.attributes, form)) {
62
+ /**
63
+ * ⛔ A DECLARATIVE OR OCI ENTRY BELONGS TO THE SERVER'S CONFIG FILE (`plugin` stanzas with
64
+ * plugin_auto_register), which re-registers it on every plugin reload. An API write turns
65
+ * it non-declarative, and the next reload then REFUSES the config entry: "conflicts with
66
+ * existing non-declarative plugin" (plugin_catalog.go:333-336).
67
+ */
68
+ if (found.live['declarative'] === true || found.live['oci'] === true) {
69
+ return yield* refuse(
70
+ form,
71
+ 'the live entry is declarative/OCI; manage it in server config.',
72
+ );
73
+ }
74
+ /**
75
+ * ⛔ AN UNVERSIONED WRITE OVER A BUILTIN SHADOWS IT FOR EVERY MOUNT OF THAT TYPE. The catalog
76
+ * answered with OpenBao's own builtin (`builtin: true`), and get() returns an external entry
77
+ * before it ever looks at builtins (plugin_catalog.go:982-1036). Register a versioned plugin
78
+ * instead, which leaves the builtin where it is.
79
+ */
80
+ if (found.attributes.builtin) {
81
+ return yield* refuse(form, 'this name is a builtin; an unversioned write would shadow it.');
82
+ }
83
+ }
84
+ if (found === undefined || !matches(found.attributes, form)) yield* registerPlugin(form);
85
+
86
+ // ⚠️ RE-READ AND COMPARE, never echo the declaration — see SELF_REPORTED above.
87
+ const after = yield* readEntry(form);
88
+ if (after === undefined || !matches(after.attributes, form)) {
89
+ return yield* refuse(form, SELF_REPORTED);
90
+ }
91
+ return after.attributes;
92
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Bao.Plugin's calls and reconcile against a fake. The parts worth pinning are the ones the source
3
+ * read turned up: a missing entry is a real 404, a builtin answers at an unversioned name, a
4
+ * declarative entry belongs to config, and a self-versioning binary is filed under its own version.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import { BaoError } from './bao-status.ts';
9
+ import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
10
+ import { type BaoPluginProps, resolve } from './plugin-form.ts';
11
+ import { reconcilePlugin } from './plugin-reconcile.ts';
12
+ import { deregisterPlugin, readPluginData, registerPlugin } from './plugin-wire.ts';
13
+
14
+ const SUM = '0123456789abcdef'.repeat(4);
15
+ const UNVERSIONED: BaoPluginProps = {
16
+ command: 'openbao-plugin-secrets-cloudflare',
17
+ name: 'openbao-plugin-secrets-cloudflare',
18
+ sha256: SUM,
19
+ type: 'secret',
20
+ };
21
+ const PROPS: BaoPluginProps = { ...UNVERSIONED, version: 'v0.1.2' };
22
+ const FORM = resolve(PROPS);
23
+ const PATH = '/v1/sys/plugins/catalog/secret/openbao-plugin-secrets-cloudflare';
24
+ const ENTRY = {
25
+ args: [],
26
+ builtin: false,
27
+ command: PROPS.command,
28
+ declarative: false,
29
+ name: PROPS.name,
30
+ oci: false,
31
+ sha256: SUM,
32
+ version: 'v0.1.2',
33
+ };
34
+ const ABSENT = { json: { errors: [] }, status: 404 };
35
+ const found = (data: Record<string, unknown>) => ({ json: { data }, status: 200 });
36
+
37
+ /** A catalog that answers reads with `before` until a PUT lands, and with `after` from then on. */
38
+ const catalog = (before: Reply, after: Reply = found(ENTRY)) => {
39
+ let registered = false;
40
+ return (seen: Seen) => {
41
+ if (seen.method === 'PUT') {
42
+ registered = true;
43
+ return { status: 204 };
44
+ }
45
+ return registered ? after : before;
46
+ };
47
+ };
48
+
49
+ const writes = (seen: Seen[]) => seen.filter((entry) => entry.method !== 'GET');
50
+
51
+ describe('plugin wire', () => {
52
+ it('reads a missing entry as absent, and a refused read as an error', async () => {
53
+ await withFake(
54
+ () => ABSENT,
55
+ async (bao) => {
56
+ assert.equal(await run({ BAO_ADDR: bao.address }, readPluginData(FORM)), undefined);
57
+ assert.equal(bao.seen[0]?.path, `${PATH}?version=v0.1.2`);
58
+ },
59
+ );
60
+ await withFake(
61
+ () => ({ json: { errors: ['permission denied'] }, status: 403 }),
62
+ async (bao) => {
63
+ const error = await runFailure({ BAO_ADDR: bao.address }, readPluginData(FORM));
64
+ assert.ok(error instanceof BaoError && error.status === 403);
65
+ },
66
+ );
67
+ });
68
+
69
+ it('registers and deregisters at the paths `bao plugin` uses', async () => {
70
+ await withFake(
71
+ () => ({ status: 204 }),
72
+ async (bao) => {
73
+ const env = { BAO_ADDR: bao.address };
74
+ await run(env, registerPlugin(FORM));
75
+ await run(env, deregisterPlugin('secret', PROPS.name, 'v0.1.2'));
76
+ assert.deepEqual(
77
+ bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
78
+ [
79
+ [
80
+ 'PUT',
81
+ PATH,
82
+ JSON.stringify({ command: PROPS.command, sha256: SUM, version: 'v0.1.2' }),
83
+ ],
84
+ ['DELETE', `${PATH}?version=v0.1.2`, ''],
85
+ ],
86
+ );
87
+ },
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('reconcilePlugin', () => {
93
+ it('registers a missing plugin and returns what it read back', async () => {
94
+ await withFake(catalog(ABSENT), async (bao) => {
95
+ const attributes = await run({ BAO_ADDR: bao.address }, reconcilePlugin(PROPS));
96
+ assert.equal(attributes.version, 'v0.1.2');
97
+ assert.equal(writes(bao.seen).length, 1);
98
+ });
99
+ });
100
+
101
+ it('writes nothing when the live entry already matches', async () => {
102
+ await withFake(catalog(found(ENTRY)), async (bao) => {
103
+ await run({ BAO_ADDR: bao.address }, reconcilePlugin(PROPS));
104
+ assert.equal(writes(bao.seen).length, 0);
105
+ });
106
+ });
107
+
108
+ it('refuses to overwrite a declarative entry or shadow a builtin', async () => {
109
+ const cases = [
110
+ { live: found({ ...ENTRY, declarative: true, sha256: 'f'.repeat(64) }), props: PROPS },
111
+ {
112
+ live: found({ builtin: true, command: '', name: 'kv', sha256: '', version: 'v2.6.2' }),
113
+ props: { ...UNVERSIONED, name: 'kv' },
114
+ },
115
+ ];
116
+ for (const [index, { live, props }] of cases.entries()) {
117
+ await withFake(catalog(live), async (bao) => {
118
+ await assert.rejects(
119
+ run({ BAO_ADDR: bao.address }, reconcilePlugin(props)),
120
+ index === 0 ? /declarative/ : /builtin/,
121
+ );
122
+ assert.equal(writes(bao.seen).length, 0);
123
+ });
124
+ }
125
+ });
126
+
127
+ it('names the self-reported version when an unversioned write reads back as nothing', async () => {
128
+ await withFake(catalog(ABSENT, ABSENT), async (bao) => {
129
+ await assert.rejects(
130
+ run({ BAO_ADDR: bao.address }, reconcilePlugin(UNVERSIONED)),
131
+ /declare `version`/,
132
+ );
133
+ assert.equal(writes(bao.seen).length, 1);
134
+ });
135
+ });
136
+
137
+ it('refuses a bad declaration before sending anything', async () => {
138
+ await withFake(catalog(ABSENT), async (bao) => {
139
+ await assert.rejects(
140
+ run({ BAO_ADDR: bao.address }, reconcilePlugin({ ...PROPS, version: '0.1.2' })),
141
+ /canonical semver/,
142
+ );
143
+ assert.equal(bao.seen.length, 0);
144
+ });
145
+ });
146
+ });