@homeflare/alchemy 0.4.1 → 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 (47) 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/package.json +1 -1
  28. package/src/openbao/approle-login-form.test.ts +64 -0
  29. package/src/openbao/approle-login-form.ts +153 -0
  30. package/src/openbao/approle-login-result.ts +96 -0
  31. package/src/openbao/approle-login.test.ts +200 -0
  32. package/src/openbao/approle-login.ts +156 -0
  33. package/src/openbao/auth-role-form.test.ts +42 -0
  34. package/src/openbao/bao-http.ts +2 -0
  35. package/src/openbao/index.ts +13 -0
  36. package/src/openbao/mount-path.test.ts +23 -0
  37. package/src/openbao/mount-path.ts +25 -0
  38. package/src/openbao/mount-wire.test.ts +13 -0
  39. package/src/openbao/plugin-form.test.ts +86 -0
  40. package/src/openbao/plugin-form.ts +164 -0
  41. package/src/openbao/plugin-reconcile.ts +92 -0
  42. package/src/openbao/plugin-wire.test.ts +146 -0
  43. package/src/openbao/plugin-wire.ts +65 -0
  44. package/src/openbao/plugin.ts +100 -0
  45. package/src/openbao/ssh-role-form.test.ts +105 -0
  46. package/src/openbao/ssh-role-form.ts +18 -16
  47. package/src/openbao/ssh-role.ts +3 -4
@@ -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
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The OpenBao calls Bao.Plugin makes — read one catalog entry, register, deregister. Split from
3
+ * plugin.ts so the calls can be pinned against a fake server, the way mount-wire.ts is.
4
+ *
5
+ * ★ ENDPOINTS, READ FROM openbao v2.6.2 api/sys_plugins.go (the client `bao plugin` uses) and the
6
+ * handlers in vault/logical_system.go:
7
+ * read GET sys/plugins/catalog/<type>/<name>[?version=] (GetPlugin, :159-183)
8
+ * register PUT sys/plugins/catalog/<type>/<name> (RegisterPlugin, :212-228)
9
+ * deregister DELETE sys/plugins/catalog/<type>/<name>[?version=] (DeregisterPlugin, :249-261)
10
+ * (api/sys_plugins.go diffed identical between v2.6.0 and v2.6.2.)
11
+ * ⚠️ ALL THREE NEED `sudo` ON THE PATH, not just the verb (docs/api/system/plugins-catalog.mdx). A
12
+ * token without it reads as 403, which is an error here, never "absent".
13
+ */
14
+ import type * as Effect from 'effect/Effect';
15
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
16
+ import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
17
+ import type { BaoError } from './bao-status.ts';
18
+ import {
19
+ type BaoPluginForm,
20
+ type BaoPluginType,
21
+ catalogPath,
22
+ registerBody,
23
+ versionedPath,
24
+ } from './plugin-form.ts';
25
+
26
+ /**
27
+ * One catalog entry, or undefined when the catalog has none at that name, type and version.
28
+ *
29
+ * ★ UNLIKE A MOUNT, A MISSING PLUGIN REALLY IS A 404. handlePluginCatalogRead returns `nil, nil` when
30
+ * the catalog lookup finds nothing (vault/logical_system.go:545-547), and RespondErrorCommon turns
31
+ * a nil READ response into 404 (sdk v2.6.2 logical/response_util.go:23-26). No second question is
32
+ * needed, unlike the mount-table check in mount-wire.ts.
33
+ * ⚠️ A BUILTIN ANSWERS TOO. With no external registration at an unversioned name, the catalog falls
34
+ * through to OpenBao's own builtin of that name and type (plugin_catalog.go get, :1016-1036), which
35
+ * reads back with `builtin: true` — present, but not a registration. plugin.ts decides what that
36
+ * means.
37
+ */
38
+ export const readPluginData = (
39
+ form: BaoPluginForm,
40
+ ): Effect.Effect<Record<string, unknown> | undefined, BaoError, HttpClient.HttpClient> =>
41
+ baoRead(versionedPath(form.type, form.name, form.version));
42
+
43
+ export const registerPlugin = (
44
+ form: BaoPluginForm,
45
+ ): Effect.Effect<void, BaoError, HttpClient.HttpClient> =>
46
+ baoWrite('PUT', catalogPath(form.type, form.name), registerBody(form));
47
+
48
+ /**
49
+ * ★ IDEMPOTENT ON THE SERVER. deleteInternal deletes the storage key whether or not it exists
50
+ * (plugin_catalog.go:1272-1284) and the handler answers with no body, so a retried delete succeeds.
51
+ * ⚠️ BUT A MISSING TYPED KEY FALLS BACK TO THE BARE NAME. When `<type>/<name>[/<version>]` is not
52
+ * in storage, the same function deletes the key `<name>` instead — where registrations made
53
+ * before plugin types existed live, whatever their type. A fresh 2.x vault has none; one
54
+ * migrated from an old Vault may. REASONED FROM SOURCE, not run.
55
+ * ⛔ AND THAT IS THE DANGER: NOTHING CHECKS WHETHER A MOUNT STILL USES THE PLUGIN. The same function
56
+ * has no mount lookup at all, so deregistering the plugin a live mount runs succeeds. The running
57
+ * process is not killed by it; the mount breaks the next time OpenBao has to START the plugin — a
58
+ * reload, a restart, an unseal — long after the plan that did it. REASONED FROM SOURCE, not run.
59
+ */
60
+ export const deregisterPlugin = (
61
+ type: BaoPluginType,
62
+ name: string,
63
+ version: string,
64
+ ): Effect.Effect<void, BaoError, HttpClient.HttpClient> =>
65
+ baoDelete(versionedPath(type, name, version));
@@ -0,0 +1,100 @@
1
+ /**
2
+ * An OpenBao plugin catalog entry — `sys/plugins/catalog/<type>/<name>`: which binary in
3
+ * plugin_directory, at which SHA-256, may run as a plugin of that name. METADATA ONLY.
4
+ *
5
+ * ⛔ IT REGISTERS; IT DOES NOT DELIVER. The binary must already sit in plugin_directory. Copying it
6
+ * there is a host step.
7
+ * ⚠️ A MISSING FILE FAILS THE WRITE; A WRONG SHA-256 DOES NOT. setInternal (plugin_catalog.go
8
+ * :1150-1230) resolves the path first, which fails loudly, but the run that would notice a
9
+ * checksum mismatch is only the version probe, and that is best-effort: it logs a warning and
10
+ * stores the entry anyway. The mismatch then surfaces when a mount first starts the plugin.
11
+ * ⛔ NO `env` PROP, AND THERE MUST NEVER BE ONE — see registerBody in plugin-form.ts. Alchemy keeps
12
+ * props and attributes UNENCRYPTED in its state store (policy.ts has the reasoning).
13
+ * ⚠️ A NEW SHA-256 DOES NOT RESTART A RUNNING PLUGIN. setInternal writes the catalog entry and
14
+ * reloads nothing, so mounts keep the old process until `sys/plugins/reload/backend` or a
15
+ * restart. Reloading is an operator step, not this resource.
16
+ *
17
+ * ★ `defaultRemovalPolicy: 'retain'`. Deregistering does not check whether a mount still runs the
18
+ * plugin (the ⛔ on deregisterPlugin in plugin-wire.ts), so a destroy would break that mount at its
19
+ * next start. Retain also means a version bump — a REPLACE, below — leaves the old version
20
+ * registered for mounts pinned to it; prune it by hand once nothing uses it.
21
+ */
22
+ import { Resource } from 'alchemy';
23
+ import { isResolved } from 'alchemy/Diff';
24
+ import * as Provider from 'alchemy/Provider';
25
+ import * as Effect from 'effect/Effect';
26
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
27
+ import { type BaoPluginAttributes, type BaoPluginProps, matches, resolve } from './plugin-form.ts';
28
+ import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
29
+ import { deregisterPlugin } from './plugin-wire.ts';
30
+
31
+ export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
32
+
33
+ export interface BaoPlugin extends Resource<
34
+ 'Bao.Plugin',
35
+ BaoPluginProps,
36
+ BaoPluginAttributes,
37
+ never,
38
+ HttpClient.HttpClient
39
+ > {}
40
+
41
+ export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
42
+ defaultRemovalPolicy: 'retain',
43
+ });
44
+
45
+ export const BaoPluginProvider = () =>
46
+ Provider.effect(
47
+ BaoPlugin,
48
+ Effect.succeed(
49
+ BaoPlugin.Provider.of({
50
+ /**
51
+ * ⛔ THE CATALOG IS NOT A LIST OF THINGS THIS OWNS. It answers every builtin plugin and
52
+ * every hand registration. Returning them would invite Alchemy to adopt — and then
53
+ * deregister — plugins it never registered.
54
+ */
55
+ list: () => Effect.succeed([]),
56
+
57
+ read: Effect.fn(function* ({ olds }) {
58
+ const found = yield* readEntry(resolve(olds));
59
+ return found?.attributes;
60
+ }),
61
+
62
+ /** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
63
+ diff: Effect.fn(function* ({ news, output }) {
64
+ if (output === undefined || !isResolved(news)) return undefined;
65
+ const form = resolve(news);
66
+ /**
67
+ * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so changing one is a different entry,
68
+ * not an edit. Writing the new key would leave the old registration in place under a
69
+ * state record that no longer names it.
70
+ */
71
+ if (
72
+ output.name !== form.name ||
73
+ output.type !== form.type ||
74
+ output.version !== form.version
75
+ ) {
76
+ return { action: 'replace' } as const;
77
+ }
78
+ const found = yield* readEntry(form);
79
+ if (found === undefined) return { action: 'update' } as const;
80
+ return matches(found.attributes, form)
81
+ ? ({ action: 'noop' } as const)
82
+ : ({ action: 'update' } as const);
83
+ }),
84
+
85
+ /** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
86
+ reconcile: Effect.fn(function* ({ news }) {
87
+ return yield* reconcilePlugin(news);
88
+ }),
89
+
90
+ /**
91
+ * ⛔ READ THE ⛔ ON deregisterPlugin FIRST: nothing checks that a mount still runs this.
92
+ * Idempotent as Alchemy requires — already gone is success on the server.
93
+ */
94
+ delete: Effect.fn(function* ({ output }) {
95
+ yield* deregisterPlugin(output.type, output.name, output.version);
96
+ return undefined;
97
+ }),
98
+ }),
99
+ ),
100
+ );
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Bao.SshRole's two shapes — a user-cert role and a host-cert role — through writeBody and back
3
+ * through attributesOf, the round trip every plan rests on.
4
+ *
5
+ * ★ `cert_type` IS NOT A ROLE FIELD. It is a SIGN parameter, default `user` (openbao v2.6.2
6
+ * builtin/logical/ssh/path_sign.go:50-54); a host-cert role is `allow_host_certificates` plus the
7
+ * domain fields, and the host asks for `cert_type=host` when it signs. path_roles.go has no such
8
+ * field, so nothing here sends one.
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { describe, it } from 'node:test';
12
+ import {
13
+ type BaoSshRoleProps,
14
+ attributesOf,
15
+ matches,
16
+ resolve,
17
+ writeBody,
18
+ } from './ssh-role-form.ts';
19
+
20
+ const USER: BaoSshRoleProps = {
21
+ allowUserCertificates: true,
22
+ allowedUsers: ['ops', 'deploy'],
23
+ defaultExtensions: { 'permit-pty': '', 'permit-agent-forwarding': '' },
24
+ maxTtl: '16h',
25
+ name: 'operator',
26
+ ttl: '16h',
27
+ };
28
+
29
+ const HOST: BaoSshRoleProps = {
30
+ allowHostCertificates: true,
31
+ allowSubdomains: true,
32
+ allowedDomains: ['example.test', 'lab.example.test'],
33
+ allowedUsers: [],
34
+ maxTtl: '8760h',
35
+ mount: 'ssh-host/',
36
+ name: 'host',
37
+ ttl: '8760h',
38
+ };
39
+
40
+ describe('ssh role writeBody', () => {
41
+ it('sends the maps as JSON objects, never as JSON strings (sdk TypeMap refuses a string)', () => {
42
+ const body = writeBody(resolve(USER));
43
+ assert.deepEqual(body['default_extensions'], {
44
+ 'permit-agent-forwarding': '',
45
+ 'permit-pty': '',
46
+ });
47
+ assert.deepEqual(body['default_critical_options'], {});
48
+ assert.equal(typeof body['default_extensions'], 'object');
49
+ });
50
+
51
+ it('writes a user-cert role with key_type ca, sorted principals and a stated TTL', () => {
52
+ const body = writeBody(resolve(USER));
53
+ assert.equal(body['key_type'], 'ca');
54
+ assert.equal(body['allowed_users'], 'deploy,ops');
55
+ assert.equal(body['allow_user_certificates'], 'true');
56
+ assert.equal(body['allow_host_certificates'], 'false');
57
+ assert.equal(body['ttl'], '16h');
58
+ assert.equal('cert_type' in body, false);
59
+ });
60
+
61
+ it('writes a host-cert role with domains and subdomains on its own mount', () => {
62
+ const form = resolve(HOST);
63
+ const body = writeBody(form);
64
+ assert.equal(form.mount, 'ssh-host');
65
+ assert.equal(body['allow_host_certificates'], 'true');
66
+ assert.equal(body['allow_user_certificates'], 'false');
67
+ assert.equal(body['allowed_domains'], 'example.test,lab.example.test');
68
+ assert.equal(body['allow_subdomains'], 'true');
69
+ assert.equal(body['allowed_users'], '');
70
+ });
71
+ });
72
+
73
+ describe('ssh role round trip', () => {
74
+ it('matches the role OpenBao reads back — seconds, objects, comma strings', () => {
75
+ const live = {
76
+ allow_host_certificates: false,
77
+ allow_user_certificates: true,
78
+ allowed_users: 'ops,deploy',
79
+ default_critical_options: {},
80
+ default_extensions: { 'permit-agent-forwarding': '', 'permit-pty': '' },
81
+ key_type: 'ca',
82
+ max_ttl: 57600,
83
+ ttl: 57600,
84
+ };
85
+ const form = resolve(USER);
86
+ assert.equal(matches(attributesOf(form, live), form), true);
87
+ assert.equal(matches(attributesOf(form, { ...live, ttl: 14400 }), form), false);
88
+ });
89
+
90
+ it('matches a host role read back', () => {
91
+ const live = {
92
+ allow_host_certificates: true,
93
+ allow_subdomains: true,
94
+ allowed_domains: 'lab.example.test,example.test',
95
+ allowed_users: '',
96
+ default_critical_options: {},
97
+ default_extensions: {},
98
+ key_type: 'ca',
99
+ max_ttl: 31536000,
100
+ ttl: 31536000,
101
+ };
102
+ const form = resolve(HOST);
103
+ assert.equal(matches(attributesOf(form, live), form), true);
104
+ });
105
+ });