@graphorin/secret-1password 0.6.1 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @graphorin/secret-1password
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-072: every export map's `import` condition becomes `default`, and the Node floor rises to `>=22.12.0`.
8
+
9
+ CJS consumers previously hit a bewildering `ERR_PACKAGE_PATH_NOT_EXPORTED` instead of a clear ESM-only signal. With the `default` condition, plain `require('@graphorin/core')` works via Node's stable `require(esm)` - which shipped in 22.12, hence the engines bump across every workspace manifest (packages, examples, benchmarks, docs; enforced by the widened mvp-readiness sweep). No dual-instance hazard: there is no CJS build, `require()` returns the same ESM module instance. ESM consumers are unaffected (`default` serves both paths; `types` stays first). The pack gate now runs attw under the full `node16` profile (was `esm-only`) and adds a runtime `require(esm)` smoke against the packed tarballs. Installs on Node 22.0-22.11 with `engine-strict` will refuse - upgrade Node (see the migration guide).
10
+
11
+ ### Patch Changes
12
+
13
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Tarballs now ship `src/` so the published `dist/**/*.d.ts.map` files actually work (W-136): the maps referenced `../src/*.ts` that the `files` whitelist excluded, so go-to-definition fell back into `.d.ts` and the shipped maps were dead weight. The pack gate gains a `map-integrity` leg: every source referenced by a shipped map must resolve inside the tarball (or be embedded via `sourcesContent`), with an anti-vacuous guard - a package whose tsdown config emits declaration maps must contain a non-zero number of `.d.ts.map` files, so a cache-restored dist that silently dropped maps fails the gate instead of passing vacuously. `mvp-readiness` now requires `src` in every publishable `files` array.
14
+
15
+ - Updated dependencies [[`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04)]:
16
+ - @graphorin/core@0.7.0
17
+ - @graphorin/security@0.7.0
18
+
3
19
  ## 0.6.1
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -6,14 +6,14 @@
6
6
  > pluggable `SecretResolver` registry by shelling out to the official
7
7
  > 1Password CLI (`op read 'op://<vault>/<item>/<field>'`).
8
8
  >
9
- > Project Graphorin · v0.6.1 · MIT License · © 2026 Oleksiy Stepurenko ·
9
+ > Project Graphorin · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko ·
10
10
  > <https://github.com/o-stepper/graphorin>
11
11
 
12
12
  ---
13
13
 
14
14
  ## Status
15
15
 
16
- - **Published:** v0.6.1 (optional sub-pack)
16
+ - **Published:** v0.7.0 (optional sub-pack)
17
17
  - Reference adapter - community packages should follow this template
18
18
  when wiring HashiCorp Vault, AWS Secrets Manager, GCP Secret
19
19
  Manager, Azure Key Vault, Bitwarden, or Unix `pass`.
@@ -167,4 +167,4 @@ MIT © 2026 Oleksiy Stepurenko
167
167
 
168
168
  ---
169
169
 
170
- **Project Graphorin** · v0.6.1 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
170
+ **Project Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.6.1";
2
+ var version = "0.7.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -1 +1 @@
1
- {"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/secret-1password\",\n \"version\": \"0.6.1\",\n \"description\": \"Reference 1Password secret-resolver adapter for the Graphorin framework. Registers the `op://` scheme on top of `@graphorin/security`'s pluggable `SecretResolver` registry by shelling out to the official 1Password CLI (`op read 'op://<vault>/<item>/<field>'`). The resolver enforces a strict timeout, validates the URI before invoking the CLI, redacts the resolved value behind a `SecretValue`, and surfaces actionable errors when the CLI is missing, the user is not signed in, or the requested field is not present. Serves as the canonical template community packages should follow when wiring HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Bitwarden, or Unix `pass` as additional `SecretResolver` implementations. Created and maintained by Oleksiy Stepurenko.\",\n \"license\": \"MIT\",\n \"author\": \"Oleksiy Stepurenko\",\n \"homepage\": \"https://github.com/o-stepper/graphorin/tree/main/packages/secret-1password\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/o-stepper/graphorin.git\",\n \"directory\": \"packages/secret-1password\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/o-stepper/graphorin/issues\"\n },\n \"keywords\": [\n \"graphorin\",\n \"ai\",\n \"agents\",\n \"framework\",\n \"secrets\",\n \"1password\",\n \"op-cli\",\n \"secret-resolver\",\n \"secret-ref\"\n ],\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"main\": \"./dist/index.js\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"sideEffects\": false,\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\"\n },\n \"./package.json\": \"./package.json\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsdown\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.tests.json\",\n \"test\": \"vitest run\",\n \"lint\": \"biome check .\",\n \"clean\": \"rimraf dist .turbo *.tsbuildinfo\"\n },\n \"dependencies\": {\n \"@graphorin/core\": \"workspace:*\",\n \"@graphorin/security\": \"workspace:*\"\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"provenance\": true\n }\n}\n"],"mappings":";cAEa"}
1
+ {"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/secret-1password\",\n \"version\": \"0.7.0\",\n \"description\": \"Reference 1Password secret-resolver adapter for the Graphorin framework. Registers the `op://` scheme on top of `@graphorin/security`'s pluggable `SecretResolver` registry by shelling out to the official 1Password CLI (`op read 'op://<vault>/<item>/<field>'`). The resolver enforces a strict timeout, validates the URI before invoking the CLI, redacts the resolved value behind a `SecretValue`, and surfaces actionable errors when the CLI is missing, the user is not signed in, or the requested field is not present. Serves as the canonical template community packages should follow when wiring HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Bitwarden, or Unix `pass` as additional `SecretResolver` implementations. Created and maintained by Oleksiy Stepurenko.\",\n \"license\": \"MIT\",\n \"author\": \"Oleksiy Stepurenko\",\n \"homepage\": \"https://github.com/o-stepper/graphorin/tree/main/packages/secret-1password\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/o-stepper/graphorin.git\",\n \"directory\": \"packages/secret-1password\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/o-stepper/graphorin/issues\"\n },\n \"keywords\": [\n \"graphorin\",\n \"ai\",\n \"agents\",\n \"framework\",\n \"secrets\",\n \"1password\",\n \"op-cli\",\n \"secret-resolver\",\n \"secret-ref\"\n ],\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=22.12.0\"\n },\n \"main\": \"./dist/index.js\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"sideEffects\": false,\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"./package.json\": \"./package.json\"\n },\n \"files\": [\n \"dist\",\n \"src\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsdown\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.tests.json\",\n \"test\": \"vitest run\",\n \"lint\": \"biome check .\",\n \"clean\": \"rimraf dist .turbo *.tsbuildinfo\"\n },\n \"dependencies\": {\n \"@graphorin/core\": \"workspace:*\",\n \"@graphorin/security\": \"workspace:*\"\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"provenance\": true\n }\n}\n"],"mappings":";cAEa"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphorin/secret-1password",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Reference 1Password secret-resolver adapter for the Graphorin framework. Registers the `op://` scheme on top of `@graphorin/security`'s pluggable `SecretResolver` registry by shelling out to the official 1Password CLI (`op read 'op://<vault>/<item>/<field>'`). The resolver enforces a strict timeout, validates the URI before invoking the CLI, redacts the resolved value behind a `SecretValue`, and surfaces actionable errors when the CLI is missing, the user is not signed in, or the requested field is not present. Serves as the canonical template community packages should follow when wiring HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Bitwarden, or Unix `pass` as additional `SecretResolver` implementations. Created and maintained by Oleksiy Stepurenko.",
5
5
  "license": "MIT",
6
6
  "author": "Oleksiy Stepurenko",
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "type": "module",
28
28
  "engines": {
29
- "node": ">=22.0.0"
29
+ "node": ">=22.12.0"
30
30
  },
31
31
  "main": "./dist/index.js",
32
32
  "module": "./dist/index.js",
@@ -35,19 +35,20 @@
35
35
  "exports": {
36
36
  ".": {
37
37
  "types": "./dist/index.d.ts",
38
- "import": "./dist/index.js"
38
+ "default": "./dist/index.js"
39
39
  },
40
40
  "./package.json": "./package.json"
41
41
  },
42
42
  "files": [
43
43
  "dist",
44
+ "src",
44
45
  "README.md",
45
46
  "CHANGELOG.md",
46
47
  "LICENSE"
47
48
  ],
48
49
  "dependencies": {
49
- "@graphorin/core": "0.6.1",
50
- "@graphorin/security": "0.6.1"
50
+ "@graphorin/core": "0.7.0",
51
+ "@graphorin/security": "0.7.0"
51
52
  },
52
53
  "publishConfig": {
53
54
  "access": "public",
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @graphorin/secret-1password - reference 1Password secret-resolver
3
+ * adapter for the Graphorin framework.
4
+ *
5
+ * Registers the `op://` scheme on top of `@graphorin/security`'s
6
+ * pluggable `SecretResolver` registry by shelling out to the official
7
+ * 1Password CLI:
8
+ *
9
+ * ```ts
10
+ * import { registerResolver } from '@graphorin/security';
11
+ * import { onePasswordResolver } from '@graphorin/secret-1password';
12
+ *
13
+ * registerResolver(onePasswordResolver);
14
+ *
15
+ * // Anywhere downstream:
16
+ * const apiKey = await resolveSecret('op://Personal/OpenAI/api-key');
17
+ * ```
18
+ *
19
+ * The adapter is also the canonical template community packages
20
+ * should follow when wiring HashiCorp Vault, AWS Secrets Manager, GCP
21
+ * Secret Manager, Azure Key Vault, Bitwarden, or Unix `pass` as
22
+ * additional `SecretResolver` implementations.
23
+ *
24
+ * @packageDocumentation
25
+ */
26
+
27
+ /** Canonical version constant, derived from `package.json` at build time. */
28
+ import pkg from '../package.json' with { type: 'json' };
29
+
30
+ export const VERSION: string = pkg.version;
31
+
32
+ export {
33
+ createDefaultOpCli,
34
+ createOpCli,
35
+ type OpCli,
36
+ OpCliError,
37
+ type OpCliErrorKind,
38
+ type OpCliReadOptions,
39
+ type OpCliReadResult,
40
+ } from './op-cli.js';
41
+ export {
42
+ createOnePasswordResolver,
43
+ normalizeOpUri,
44
+ type OnePasswordResolverOptions,
45
+ onePasswordResolver,
46
+ } from './resolver.js';
package/src/op-cli.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Thin wrapper around the 1Password CLI (`op`). Spawns `op` with the
3
+ * canonical `read` subcommand, captures stdout, and surfaces typed
4
+ * errors for the most common operator-fixable failure modes.
5
+ *
6
+ * The wrapper is split out so the resolver can be unit-tested without
7
+ * touching the real `op` binary - tests inject a stub `OpCli`
8
+ * implementation through {@link createOnePasswordResolver}.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { spawn } from 'node:child_process';
14
+
15
+ /** @stable */
16
+ export interface OpCliReadResult {
17
+ readonly value: string;
18
+ readonly exitCode: number;
19
+ readonly durationMs: number;
20
+ }
21
+
22
+ /** @stable */
23
+ export interface OpCli {
24
+ /**
25
+ * Resolve a single `op://...` reference. Returns the **trimmed**
26
+ * stdout. Throws {@link OpCliError} when the binary is missing, the
27
+ * user is signed out, the reference does not resolve, or the call
28
+ * exceeds the timeout.
29
+ */
30
+ read(uri: string, options?: OpCliReadOptions): Promise<OpCliReadResult>;
31
+ }
32
+
33
+ /** @stable */
34
+ export interface OpCliReadOptions {
35
+ /** Override the binary path. Default `'op'` (looked up on `$PATH`). */
36
+ readonly binary?: string;
37
+ /** Hard timeout in milliseconds. Default `15000`. */
38
+ readonly timeoutMs?: number;
39
+ /** Override `process.env`. Default forwards the parent process. */
40
+ readonly env?: Readonly<Record<string, string | undefined>>;
41
+ /** Optional 1Password Connect / Service-Account token forwarding. */
42
+ readonly serviceAccountToken?: string;
43
+ /**
44
+ * Optional 1Password Connect host + token tuple. When set the
45
+ * resolver wires them through the `OP_CONNECT_HOST` /
46
+ * `OP_CONNECT_TOKEN` env vars (the canonical Connect-mode contract
47
+ * documented by 1Password).
48
+ */
49
+ readonly connect?: { readonly host: string; readonly token: string };
50
+ /**
51
+ * Optional `--account` override forwarded to the CLI. Useful when
52
+ * the operator is signed in to multiple 1Password accounts.
53
+ */
54
+ readonly account?: string;
55
+ /**
56
+ * Optional `--no-color` flag suppression. The resolver always sets
57
+ * `--no-color` so terminal colour codes do not leak into the
58
+ * resolved value; pass `true` to opt out.
59
+ */
60
+ readonly preserveColor?: boolean;
61
+ }
62
+
63
+ /**
64
+ * Typed error raised by the CLI wrapper. Carries a `kind` so callers
65
+ * can distinguish operator-fixable failure modes.
66
+ *
67
+ * @stable
68
+ */
69
+ export class OpCliError extends Error {
70
+ override readonly name = 'OpCliError';
71
+ readonly kind: OpCliErrorKind;
72
+ readonly exitCode?: number;
73
+ readonly stderr?: string;
74
+ readonly hint?: string;
75
+
76
+ constructor(
77
+ kind: OpCliErrorKind,
78
+ message: string,
79
+ options?: {
80
+ cause?: unknown;
81
+ exitCode?: number;
82
+ stderr?: string;
83
+ hint?: string;
84
+ },
85
+ ) {
86
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
87
+ this.kind = kind;
88
+ if (options?.exitCode !== undefined) this.exitCode = options.exitCode;
89
+ if (options?.stderr !== undefined) this.stderr = options.stderr;
90
+ if (options?.hint !== undefined) this.hint = options.hint;
91
+ }
92
+ }
93
+
94
+ /** @stable */
95
+ export type OpCliErrorKind =
96
+ | 'binary-missing'
97
+ | 'signed-out'
98
+ | 'reference-not-found'
99
+ | 'timeout'
100
+ | 'unknown';
101
+
102
+ /**
103
+ * Default {@link OpCli} implementation. Spawns `op read --no-color
104
+ * --reveal '<uri>'` with the configured timeout and inherits the
105
+ * parent environment.
106
+ *
107
+ * @stable
108
+ */
109
+ export function createDefaultOpCli(): OpCli {
110
+ return createOpCli();
111
+ }
112
+
113
+ /**
114
+ * Grace period after SIGTERM before escalating to SIGKILL (SPL-22). A
115
+ * well-behaved `op` exits on SIGTERM well within this window; a wedged one
116
+ * (ignoring SIGTERM in an uninterruptible read) is force-killed so the hard
117
+ * timeout actually settles the promise.
118
+ */
119
+ const SIGKILL_GRACE_MS = 1_000;
120
+
121
+ /**
122
+ * {@link OpCli} factory with an injectable `spawn` (for tests). Production
123
+ * code uses {@link createDefaultOpCli}.
124
+ *
125
+ * @stable
126
+ */
127
+ export function createOpCli(deps: { readonly spawn?: typeof spawn } = {}): OpCli {
128
+ const spawnFn = deps.spawn ?? spawn;
129
+ return {
130
+ async read(uri: string, options: OpCliReadOptions = {}): Promise<OpCliReadResult> {
131
+ const binary = options.binary ?? 'op';
132
+ const timeoutMs = options.timeoutMs ?? 15_000;
133
+ const args = ['read', '--reveal'];
134
+ if (options.preserveColor !== true) args.push('--no-color');
135
+ if (options.account !== undefined && options.account.length > 0) {
136
+ args.push('--account', options.account);
137
+ }
138
+ args.push(uri);
139
+ const env: Record<string, string> = {};
140
+ const baseEnv = options.env ?? process.env;
141
+ for (const [k, v] of Object.entries(baseEnv)) {
142
+ if (typeof v === 'string') env[k] = v;
143
+ }
144
+ if (options.serviceAccountToken !== undefined) {
145
+ env.OP_SERVICE_ACCOUNT_TOKEN = options.serviceAccountToken;
146
+ }
147
+ if (options.connect !== undefined) {
148
+ env.OP_CONNECT_HOST = options.connect.host;
149
+ env.OP_CONNECT_TOKEN = options.connect.token;
150
+ }
151
+ const started = performance.now();
152
+ return new Promise<OpCliReadResult>((resolve, reject) => {
153
+ let stdout = '';
154
+ let stderr = '';
155
+ let killedByTimeout = false;
156
+ let proc: ReturnType<typeof spawn>;
157
+ try {
158
+ proc = spawnFn(binary, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
159
+ } catch (err) {
160
+ reject(
161
+ new OpCliError(
162
+ 'binary-missing',
163
+ `'${binary}' is not installed or not on PATH. Install the 1Password CLI: https://developer.1password.com/docs/cli/get-started`,
164
+ { cause: err, hint: 'install the 1Password CLI' },
165
+ ),
166
+ );
167
+ return;
168
+ }
169
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
170
+ const timer = setTimeout(() => {
171
+ killedByTimeout = true;
172
+ try {
173
+ proc.kill('SIGTERM');
174
+ } catch {
175
+ // best-effort
176
+ }
177
+ // SPL-22: SIGTERM alone can hang forever if `op` ignores it. Escalate
178
+ // to SIGKILL after a short grace AND reject from the timer, so the
179
+ // promise settles even when `close` never fires.
180
+ graceTimer = setTimeout(() => {
181
+ try {
182
+ proc.kill('SIGKILL');
183
+ } catch {
184
+ // best-effort
185
+ }
186
+ reject(
187
+ new OpCliError(
188
+ 'timeout',
189
+ `op CLI timed out after ${timeoutMs} ms while resolving '${uri}' and did not exit on SIGTERM.`,
190
+ { stderr, hint: 'increase --op-timeout-ms or check 1Password connectivity' },
191
+ ),
192
+ );
193
+ }, SIGKILL_GRACE_MS);
194
+ graceTimer.unref?.();
195
+ }, timeoutMs);
196
+ timer.unref?.();
197
+ proc.stdout?.on('data', (chunk: Buffer) => {
198
+ stdout += chunk.toString('utf8');
199
+ });
200
+ proc.stderr?.on('data', (chunk: Buffer) => {
201
+ stderr += chunk.toString('utf8');
202
+ });
203
+ proc.on('error', (err) => {
204
+ clearTimeout(timer);
205
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
206
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
207
+ reject(
208
+ new OpCliError(
209
+ 'binary-missing',
210
+ `'${binary}' is not installed or not on PATH. Install the 1Password CLI: https://developer.1password.com/docs/cli/get-started`,
211
+ { cause: err, hint: 'install the 1Password CLI' },
212
+ ),
213
+ );
214
+ return;
215
+ }
216
+ reject(
217
+ new OpCliError('unknown', `op CLI invocation failed: ${err.message}`, {
218
+ cause: err,
219
+ }),
220
+ );
221
+ });
222
+ proc.on('close', (code) => {
223
+ clearTimeout(timer);
224
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
225
+ const durationMs = performance.now() - started;
226
+ if (killedByTimeout) {
227
+ reject(
228
+ new OpCliError(
229
+ 'timeout',
230
+ `op CLI timed out after ${timeoutMs} ms while resolving '${uri}'.`,
231
+ { stderr, hint: 'increase --op-timeout-ms or check 1Password connectivity' },
232
+ ),
233
+ );
234
+ return;
235
+ }
236
+ if (code !== 0) {
237
+ const kind = classifyExitError(code ?? -1, stderr);
238
+ reject(
239
+ new OpCliError(
240
+ kind,
241
+ `op CLI exited with code ${code} for '${uri}'${stderr.length > 0 ? `: ${stderr.trim()}` : ''}`,
242
+ {
243
+ ...(code !== null ? { exitCode: code } : {}),
244
+ stderr,
245
+ hint: hintForExitKind(kind),
246
+ },
247
+ ),
248
+ );
249
+ return;
250
+ }
251
+ resolve(Object.freeze({ value: stdout.replace(/\r?\n$/, ''), exitCode: 0, durationMs }));
252
+ });
253
+ });
254
+ },
255
+ };
256
+ }
257
+
258
+ function classifyExitError(_code: number, stderr: string): OpCliErrorKind {
259
+ const lower = stderr.toLowerCase();
260
+ if (
261
+ lower.includes('not signed in') ||
262
+ lower.includes('please sign in') ||
263
+ lower.includes('not authenticated') ||
264
+ lower.includes('session expired')
265
+ ) {
266
+ return 'signed-out';
267
+ }
268
+ if (
269
+ lower.includes("couldn't find") ||
270
+ lower.includes('does not exist') ||
271
+ lower.includes('item not found') ||
272
+ lower.includes('field not found') ||
273
+ lower.includes('reference not found')
274
+ ) {
275
+ return 'reference-not-found';
276
+ }
277
+ return 'unknown';
278
+ }
279
+
280
+ function hintForExitKind(kind: OpCliErrorKind): string {
281
+ switch (kind) {
282
+ case 'signed-out':
283
+ return "run 'eval $(op signin)' (interactive) or set OP_SERVICE_ACCOUNT_TOKEN (headless).";
284
+ case 'reference-not-found':
285
+ return "verify the reference with 'op item get <item> --vault <vault>' before retrying.";
286
+ case 'timeout':
287
+ return 'check 1Password connectivity / increase --op-timeout-ms.';
288
+ case 'binary-missing':
289
+ return 'install the 1Password CLI: https://developer.1password.com/docs/cli/get-started';
290
+ default:
291
+ return 'check the op CLI stderr output for details.';
292
+ }
293
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `op://`-scheme `SecretResolver` for the Graphorin framework. Wraps
3
+ * the official 1Password CLI (`op`) per the canonical `op read` flow
4
+ * documented at https://developer.1password.com/docs/cli/secrets-references/.
5
+ *
6
+ * URI shape (per 1Password): `op://<vault>/<item>/[section/]<field>`.
7
+ * The resolver accepts any `op://` URI the CLI accepts (the CLI is
8
+ * the source of truth - we forward verbatim) and rejects only the
9
+ * obvious malformed cases at parse time.
10
+ *
11
+ * 1Password explicitly treats vault / item / field names as
12
+ * case-insensitive; the resolver normalises the URI by lowercasing the
13
+ * authority + path before forwarding to the CLI so two configs that
14
+ * differ only in case resolve to the same value.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import type { SecretResolver, SecretResolverContext } from '@graphorin/core/contracts';
20
+ import { type ParsedSecretRef, SecretResolutionError, SecretValue } from '@graphorin/security';
21
+
22
+ import { createDefaultOpCli, type OpCli, OpCliError } from './op-cli.js';
23
+
24
+ /** @stable */
25
+ export interface OnePasswordResolverOptions {
26
+ /**
27
+ * Inject a {@link OpCli} implementation. Defaults to a wrapper that
28
+ * spawns the system `op` binary. Tests pass a stub.
29
+ */
30
+ readonly cli?: OpCli;
31
+ /** Override the CLI binary path. Default `'op'`. */
32
+ readonly binary?: string;
33
+ /** Hard timeout per resolve. Default `15000` ms. */
34
+ readonly timeoutMs?: number;
35
+ /**
36
+ * Optional service-account token. When set the resolver forwards it
37
+ * via `OP_SERVICE_ACCOUNT_TOKEN` so the CLI runs in headless mode.
38
+ * The token is itself a secret - pass a previously-resolved
39
+ * `SecretValue` and use `.use(...)` to scope its lifetime.
40
+ */
41
+ readonly serviceAccountToken?: string;
42
+ /**
43
+ * Optional 1Password Connect host + token. Mutually exclusive with
44
+ * a service-account token at the CLI level (the CLI honours the
45
+ * Connect env vars if both are present).
46
+ */
47
+ readonly connect?: { readonly host: string; readonly token: string };
48
+ /**
49
+ * Optional `--account` override. Useful when the operator is signed
50
+ * in to multiple 1Password accounts.
51
+ */
52
+ readonly account?: string;
53
+ /**
54
+ * If `true`, do **not** lowercase the URI before forwarding to the
55
+ * CLI. Default `false`. Toggle only when interoperating with a
56
+ * deployment that intentionally relies on case-sensitive keys.
57
+ */
58
+ readonly preserveCase?: boolean;
59
+ }
60
+
61
+ /**
62
+ * Build a `SecretResolver` that honours the `op://` scheme. Register
63
+ * with `registerResolver(...)` from `@graphorin/security` at app
64
+ * bootstrap.
65
+ *
66
+ * @stable
67
+ */
68
+ export function createOnePasswordResolver(
69
+ options: OnePasswordResolverOptions = {},
70
+ ): SecretResolver {
71
+ const cli = options.cli ?? createDefaultOpCli();
72
+ return {
73
+ scheme: 'op',
74
+ async resolve(ref, ctx?: SecretResolverContext): Promise<SecretValue> {
75
+ void ctx;
76
+ const parsed = ref as ParsedSecretRef;
77
+ assertOpRef(parsed);
78
+ const normalized = options.preserveCase === true ? parsed.raw : normalizeOpUri(parsed.raw);
79
+ try {
80
+ const result = await cli.read(normalized, {
81
+ ...(options.binary !== undefined ? { binary: options.binary } : {}),
82
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
83
+ ...(options.serviceAccountToken !== undefined
84
+ ? { serviceAccountToken: options.serviceAccountToken }
85
+ : {}),
86
+ ...(options.connect !== undefined ? { connect: options.connect } : {}),
87
+ ...(options.account !== undefined ? { account: options.account } : {}),
88
+ });
89
+ if (result.value.length === 0) {
90
+ throw new SecretResolutionError(
91
+ 'op',
92
+ parsed.raw,
93
+ '1Password CLI returned an empty value; verify the field exists and is non-empty.',
94
+ );
95
+ }
96
+ return SecretValue.fromString(result.value, {
97
+ source: { resolver: 'op', ref: parsed.raw },
98
+ });
99
+ } catch (err) {
100
+ if (err instanceof OpCliError) {
101
+ throw new SecretResolutionError(
102
+ 'op',
103
+ parsed.raw,
104
+ `${err.message}${err.hint !== undefined ? ` - hint: ${err.hint}` : ''}`,
105
+ { cause: err },
106
+ );
107
+ }
108
+ throw err;
109
+ }
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Cache the default-options resolver so callers that just want the
116
+ * happy-path behaviour can use a constant export.
117
+ *
118
+ * @stable
119
+ */
120
+ export const onePasswordResolver: SecretResolver = createOnePasswordResolver();
121
+
122
+ function assertOpRef(parsed: ParsedSecretRef): void {
123
+ if (parsed.scheme !== 'op') {
124
+ throw new SecretResolutionError(
125
+ 'op',
126
+ parsed.raw,
127
+ `expected scheme 'op', received '${parsed.scheme}'.`,
128
+ );
129
+ }
130
+ if (parsed.authority === undefined || parsed.authority.length === 0) {
131
+ throw new SecretResolutionError(
132
+ 'op',
133
+ parsed.raw,
134
+ "op:// SecretRef must include a vault authority (e.g. 'op://Personal/Item/field').",
135
+ );
136
+ }
137
+ if (parsed.path === undefined || parsed.path.length <= 1) {
138
+ throw new SecretResolutionError(
139
+ 'op',
140
+ parsed.raw,
141
+ "op:// SecretRef must include an item + field path segment (e.g. 'op://Vault/Item/field').",
142
+ );
143
+ }
144
+ const segments = parsed.path.split('/').filter((s) => s.length > 0);
145
+ if (segments.length < 2) {
146
+ throw new SecretResolutionError(
147
+ 'op',
148
+ parsed.raw,
149
+ 'op:// SecretRef must reference at least an item + field (got ' +
150
+ `${segments.length} path segment(s) after the vault).`,
151
+ );
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Lowercase the authority + path segments of an `op://` URI so two
157
+ * configs that differ only in case resolve to the same value (matching
158
+ * 1Password's case-insensitive behaviour).
159
+ *
160
+ * Exposed for tests.
161
+ *
162
+ * @stable
163
+ */
164
+ export function normalizeOpUri(raw: string): string {
165
+ // Lowercase only the part up to the first `?` or `#` so query /
166
+ // fragment components keep their original case (the CLI does not
167
+ // currently use them, but forward-compat matters).
168
+ const queryIdx = raw.indexOf('?');
169
+ const fragIdx = raw.indexOf('#');
170
+ const cuts = [queryIdx, fragIdx].filter((i) => i >= 0);
171
+ const split = cuts.length > 0 ? Math.min(...cuts) : raw.length;
172
+ const head = raw.slice(0, split).toLowerCase();
173
+ const tail = raw.slice(split);
174
+ return head + tail;
175
+ }