@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,14 @@
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
+ /** `text` without trailing `/`. */
9
+ export declare const trimTrailingSlashes: (text: string) => string;
10
+ /** `text` without leading or trailing `/`. */
11
+ export declare const trimSlashes: (text: string) => string;
12
+ /** SSH mount path without a trailing slash; `ssh` when the caller did not say. */
13
+ export declare const sshMountOf: (mount: string | undefined) => string;
14
+ //# sourceMappingURL=mount-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mount-path.d.ts","sourceRoot":"","sources":["../../src/openbao/mount-path.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,mCAAmC;AACnC,eAAO,MAAM,mBAAmB,SAAU,MAAM,KAAG,MAIlD,CAAC;AAEF,8CAA8C;AAC9C,eAAO,MAAM,WAAW,SAAU,MAAM,KAAG,MAI1C,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,UAAU,UAAW,MAAM,GAAG,SAAS,KAAG,MAClB,CAAC"}
@@ -0,0 +1,60 @@
1
+ export type BaoPluginType = 'auth' | 'database' | 'secret';
2
+ export interface BaoPluginProps {
3
+ /** Catalog name — what a mount's `type` names, e.g. `openbao-plugin-secrets-cloudflare`. */
4
+ name: string;
5
+ /** Catalog type. `secret` for a secrets engine, `auth` for an auth method. */
6
+ type: BaoPluginType;
7
+ /** Hex SHA-256 of the binary already sitting in plugin_directory. Case is normalised. */
8
+ sha256: string;
9
+ /** The binary's file name inside plugin_directory — a bare name, no path, no arguments. */
10
+ command: string;
11
+ /**
12
+ * Canonical semver with its `v`, e.g. `v0.1.2`. ⚠️ When the binary reports its own version,
13
+ * declare EXACTLY that one: any other is refused at write ("plugin version mismatch",
14
+ * plugin_catalog.go setInternal), and none at all is filed under the reported one — see the ⚠️
15
+ * on SELF_REPORTED in plugin-reconcile.ts.
16
+ */
17
+ version?: string;
18
+ /** Arguments, in order. ⛔ Never a secret: they land in Alchemy state and in every catalog read. */
19
+ args?: readonly string[];
20
+ }
21
+ /** ⛔ NO SECRET HERE — names, a hash, a file name, arguments and a digest. */
22
+ export interface BaoPluginAttributes {
23
+ name: string;
24
+ type: BaoPluginType;
25
+ /** The canonical version the catalog stored, `''` when unversioned. Delete needs it. */
26
+ version: string;
27
+ sha256: string;
28
+ command: string;
29
+ args: readonly string[];
30
+ /** True when the catalog answered with OpenBao's own builtin rather than a registration. */
31
+ builtin: boolean;
32
+ /** SHA-256 of the managed fields — safe to persist; see policy.ts. */
33
+ digest: string;
34
+ }
35
+ /** Every prop resolved to what the register call will store. */
36
+ export type BaoPluginForm = Required<BaoPluginProps>;
37
+ export declare const resolve: (props: BaoPluginProps) => BaoPluginForm;
38
+ /** Everything wrong with the declaration, or `[]`. Checked before any request. */
39
+ export declare const problems: (form: BaoPluginForm) => readonly string[];
40
+ /** `sys/plugins/catalog/<type>/<name>` — register addresses the plugin here. */
41
+ export declare const catalogPath: (type: BaoPluginType, name: string) => string;
42
+ /** Read and delete address one VERSION of the plugin. */
43
+ export declare const versionedPath: (type: BaoPluginType, name: string, version: string) => string;
44
+ /**
45
+ * The register body — the fields RegisterPluginInput sends (api v2.6.0 sys_plugins.go:186-204),
46
+ * which is what `bao plugin register` sends.
47
+ *
48
+ * ★ `type` IS LEFT OUT OF THE BODY ON PURPOSE. The Go client puts it there as a NUMBER (PluginType
49
+ * has no MarshalJSON), and the route's `type` capture is what the handler reads. The path says it.
50
+ * ⛔ NO `env`, EVER. The catalog read never returns env (handlePluginCatalogRead's data map has no
51
+ * such key), so a declared env could not be diffed, and what env exists to carry into a plugin
52
+ * process is exactly what must not sit in Alchemy's unencrypted state. ⚠️ The register call is a
53
+ * FULL REPLACE (setInternal builds a fresh entry), so an env someone set by hand is dropped by
54
+ * the next write — which only happens when a managed field has drifted.
55
+ */
56
+ export declare const registerBody: (form: BaoPluginForm) => Record<string, unknown>;
57
+ export declare const attributesOf: (form: BaoPluginForm, live: Record<string, unknown>) => BaoPluginAttributes;
58
+ /** True when the catalog already holds exactly this registration. A builtin never matches. */
59
+ export declare const matches: (attributes: BaoPluginAttributes, form: BaoPluginForm) => boolean;
60
+ //# sourceMappingURL=plugin-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-form.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin-form.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAC;AAE3D,MAAM,WAAW,cAAc;IAC7B,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,IAAI,EAAE,aAAa,CAAC;IACpB,yFAAyF;IACzF,MAAM,EAAE,MAAM,CAAC;IACf,2FAA2F;IAC3F,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mGAAmG;IACnG,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED,6EAA6E;AAC7E,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,aAAa,CAAC;IACpB,wFAAwF;IACxF,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,4FAA4F;IAC5F,OAAO,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,gEAAgE;AAChE,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,cAAc,CAAC,CAAC;AAErD,eAAO,MAAM,OAAO,UAAW,cAAc,KAAG,aAO9C,CAAC;AAWH,kFAAkF;AAClF,eAAO,MAAM,QAAQ,SAAU,aAAa,KAAG,SAAS,MAAM,EAuB7D,CAAC;AAMF,gFAAgF;AAChF,eAAO,MAAM,WAAW,SAAU,aAAa,QAAQ,MAAM,KAAG,MACzB,CAAC;AAExC,yDAAyD;AACzD,eAAO,MAAM,aAAa,SAAU,aAAa,QAAQ,MAAM,WAAW,MAAM,KAAG,MAC7B,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,SAAU,aAAa,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAKvE,CAAC;AAQH,eAAO,MAAM,YAAY,SACjB,aAAa,QACb,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,mBAWF,CAAC;AAMF,8FAA8F;AAC9F,eAAO,MAAM,OAAO,eAAgB,mBAAmB,QAAQ,aAAa,KAAG,OAKzC,CAAC"}
@@ -0,0 +1,18 @@
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 { type BaoPluginAttributes, type BaoPluginForm, type BaoPluginProps } from './plugin-form.ts';
9
+ type Entry = {
10
+ readonly attributes: BaoPluginAttributes;
11
+ readonly live: Record<string, unknown>;
12
+ };
13
+ /** Live entry, plus the raw body — reconcile needs the raw half for `oci` and `declarative`. */
14
+ export declare const readEntry: (form: BaoPluginForm) => Effect.Effect<Entry | undefined, BaoError, HttpClient.HttpClient>;
15
+ /** Register when the live entry differs, then prove it by reading back. Dies on a refusal. */
16
+ export declare const reconcilePlugin: (props: BaoPluginProps) => Effect.Effect<BaoPluginAttributes, BaoError, HttpClient.HttpClient>;
17
+ export {};
18
+ //# sourceMappingURL=plugin-reconcile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-reconcile.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin-reconcile.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,aAAa,EAClB,KAAK,cAAc,EAMpB,MAAM,kBAAkB,CAAC;AAG1B,KAAK,KAAK,GAAG;IAAE,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAElG,gGAAgG;AAChG,eAAO,MAAM,SAAS,SACd,aAAa,KAClB,MAAM,CAAC,MAAM,CAAC,KAAK,GAAG,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAGhE,CAAC;AAuBJ,8FAA8F;AAC9F,eAAO,MAAM,eAAe,UACnB,cAAc,KACpB,MAAM,CAAC,MAAM,CAAC,mBAAmB,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAsCjE,CAAC"}
@@ -0,0 +1,45 @@
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 type { BaoError } from './bao-status.ts';
17
+ import { type BaoPluginForm, type BaoPluginType } from './plugin-form.ts';
18
+ /**
19
+ * One catalog entry, or undefined when the catalog has none at that name, type and version.
20
+ *
21
+ * ★ UNLIKE A MOUNT, A MISSING PLUGIN REALLY IS A 404. handlePluginCatalogRead returns `nil, nil` when
22
+ * the catalog lookup finds nothing (vault/logical_system.go:545-547), and RespondErrorCommon turns
23
+ * a nil READ response into 404 (sdk v2.6.2 logical/response_util.go:23-26). No second question is
24
+ * needed, unlike the mount-table check in mount-wire.ts.
25
+ * ⚠️ A BUILTIN ANSWERS TOO. With no external registration at an unversioned name, the catalog falls
26
+ * through to OpenBao's own builtin of that name and type (plugin_catalog.go get, :1016-1036), which
27
+ * reads back with `builtin: true` — present, but not a registration. plugin.ts decides what that
28
+ * means.
29
+ */
30
+ export declare const readPluginData: (form: BaoPluginForm) => Effect.Effect<Record<string, unknown> | undefined, BaoError, HttpClient.HttpClient>;
31
+ export declare const registerPlugin: (form: BaoPluginForm) => Effect.Effect<void, BaoError, HttpClient.HttpClient>;
32
+ /**
33
+ * ★ IDEMPOTENT ON THE SERVER. deleteInternal deletes the storage key whether or not it exists
34
+ * (plugin_catalog.go:1272-1284) and the handler answers with no body, so a retried delete succeeds.
35
+ * ⚠️ BUT A MISSING TYPED KEY FALLS BACK TO THE BARE NAME. When `<type>/<name>[/<version>]` is not
36
+ * in storage, the same function deletes the key `<name>` instead — where registrations made
37
+ * before plugin types existed live, whatever their type. A fresh 2.x vault has none; one
38
+ * migrated from an old Vault may. REASONED FROM SOURCE, not run.
39
+ * ⛔ AND THAT IS THE DANGER: NOTHING CHECKS WHETHER A MOUNT STILL USES THE PLUGIN. The same function
40
+ * has no mount lookup at all, so deregistering the plugin a live mount runs succeeds. The running
41
+ * process is not killed by it; the mount breaks the next time OpenBao has to START the plugin — a
42
+ * reload, a restart, an unseal — long after the plan that did it. REASONED FROM SOURCE, not run.
43
+ */
44
+ export declare const deregisterPlugin: (type: BaoPluginType, name: string, version: string) => Effect.Effect<void, BaoError, HttpClient.HttpClient>;
45
+ //# sourceMappingURL=plugin-wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-wire.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin-wire.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,KAAK,MAAM,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAInB,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,SACnB,aAAa,KAClB,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CACzB,CAAC;AAE7D,eAAO,MAAM,cAAc,SACnB,aAAa,KAClB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CACkB,CAAC;AAEzE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,SACrB,aAAa,QACb,MAAM,WACH,MAAM,KACd,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CACP,CAAC"}
@@ -0,0 +1,31 @@
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 * as Provider from 'alchemy/Provider';
24
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
25
+ import { type BaoPluginAttributes, type BaoPluginProps } from './plugin-form.ts';
26
+ export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
27
+ export interface BaoPlugin extends Resource<'Bao.Plugin', BaoPluginProps, BaoPluginAttributes, never, HttpClient.HttpClient> {
28
+ }
29
+ export declare const BaoPlugin: import("alchemy").ResourceClass<BaoPlugin>;
30
+ export declare const BaoPluginProvider: () => import("effect/Layer").Layer<Provider.Provider<BaoPlugin>, never, HttpClient.HttpClient>;
31
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,cAAc,EAAoB,MAAM,kBAAkB,CAAC;AAInG,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAE3F,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAEH,eAAO,MAAM,iBAAiB,gGAuD3B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { sshMountOf as mountOf } from './mount-path.ts';
1
2
  export interface BaoSshRoleProps {
2
3
  /** Role name, as `<mount>/roles/{name}` takes it. */
3
4
  name: string;
@@ -57,8 +58,7 @@ export interface BaoSshRoleAttributes extends Omit<BaoSshRoleForm, 'maxTtl' | 't
57
58
  /** SHA-256 of the managed fields — safe to persist; see policy.ts. */
58
59
  digest: string;
59
60
  }
60
- /** Mount path without a trailing slash; `ssh` when the caller did not say. */
61
- export declare const mountOf: (mount: string | undefined) => string;
61
+ export { mountOf };
62
62
  export declare const readPath: (mount: string | undefined, name: string) => string;
63
63
  /**
64
64
  * ⚠️ SETS, NOT SEQUENCES. OpenBao stores these as one comma-separated string and promises
@@ -77,10 +77,11 @@ export declare const attributesOf: (form: BaoSshRoleForm, live: Record<string, u
77
77
  /**
78
78
  * ⛔ EVERY MANAGED FIELD IS SENT ON EVERY WRITE, INCLUDING THE FALSE ONES. The role write is
79
79
  * a full replace (see ssh-role.ts), so an omitted field is not "left alone" — it is reset.
80
- * Sending the resolved form makes the body and the declaration the same statement. All
81
- * strings, exactly the `k=v` pairs `bao write` sent, for `PUT <mount>/roles/<name>`.
80
+ * Sending the resolved form makes the body and the declaration the same statement. Strings,
81
+ * exactly the `k=v` pairs `bao write` sent, for `PUT <mount>/roles/<name>` — except the two
82
+ * maps, which must be objects (the ⛔ on mapValue).
82
83
  */
83
- export declare const writeBody: (form: BaoSshRoleForm) => Record<string, string>;
84
+ export declare const writeBody: (form: BaoSshRoleForm) => Record<string, unknown>;
84
85
  /**
85
86
  * ⛔ AN UNPARSEABLE TTL IS A PLAN THAT NEVER GOES GREEN. parseDuration understands `0` and
86
87
  * `<n>[smhd]` and nothing else. A prop like `4h30m` writes fine, reads back as 16200,
@@ -1 +1 @@
1
- {"version":3,"file":"ssh-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-form.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sEAAsE;IACtE,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,qFAAqF;IACrF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,oFAAoF;IACpF,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,0FAA0F;IAC1F,sBAAsB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,eAAe,CAAC,CAAC;AAEvD,sFAAsF;AACtF,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,KAAK,CAAC;IAClF;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,8EAA8E;AAC9E,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,SAAS,WAAyC,CAAC;AAE3F,eAAO,MAAM,QAAQ,UAAW,MAAM,GAAG,SAAS,QAAQ,MAAM,WAC7B,CAAC;AAMpC;;;;GAIG;AACH,eAAO,MAAM,OAAO,UAAW,OAAO,KAAG,SAAS,MAAM,EAOvD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAM5D,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,eAAe,KAAG,cAiB/C,CAAC;AAEH,eAAO,MAAM,YAAY,SACjB,cAAc,QACd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,oBAqBF,CAAC;AAgBF;;;;;GAKG;AACH,eAAO,MAAM,SAAS,SAAU,cAAc,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAgBpE,CAAC;AAoBH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,cAAc,KAAG,SAAS,MAAM,EACuB,CAAC;AAE3F;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,eAAgB,oBAAoB,QAAQ,cAAc,YAe9B,CAAC"}
1
+ {"version":3,"file":"ssh-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-form.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,UAAU,IAAI,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sEAAsE;IACtE,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,qFAAqF;IACrF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,oFAAoF;IACpF,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,0FAA0F;IAC1F,sBAAsB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,eAAe,CAAC,CAAC;AAEvD,sFAAsF;AACtF,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,KAAK,CAAC;IAClF;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB,eAAO,MAAM,QAAQ,UAAW,MAAM,GAAG,SAAS,QAAQ,MAAM,WAC7B,CAAC;AAMpC;;;;GAIG;AACH,eAAO,MAAM,OAAO,UAAW,OAAO,KAAG,SAAS,MAAM,EAOvD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAM5D,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,eAAe,KAAG,cAiB/C,CAAC;AAEH,eAAO,MAAM,YAAY,SACjB,cAAc,QACd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,oBAqBF,CAAC;AAiBF;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,SAAU,cAAc,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAgBrE,CAAC;AAoBH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,cAAc,KAAG,SAAS,MAAM,EACuB,CAAC;AAE3F;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,eAAgB,oBAAoB,QAAQ,cAAc,YAe9B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,iGAsH5B,CAAC"}
1
+ {"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,iGAqH5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo and Talos.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -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
+ };