configorama 1.0.2 → 1.1.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.
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "configorama-cloudformation",
3
+ "version": "0.1.1",
4
+ "description": "CloudFormation variable source plugin for configorama",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node index.test.js",
8
+ "publish": "git push origin && git push origin --tags",
9
+ "release:patch": "npm version patch && npm publish",
10
+ "release:minor": "npm version minor && npm publish",
11
+ "release:major": "npm version major && npm publish"
12
+ },
13
+ "keywords": [
14
+ "configorama",
15
+ "cloudformation",
16
+ "aws",
17
+ "serverless"
18
+ ],
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "@aws-sdk/client-cloudformation": "^3.700.0",
22
+ "@aws-sdk/credential-providers": "^3.700.0"
23
+ }
24
+ }
@@ -0,0 +1,175 @@
1
+ # configorama 1Password plugin
2
+
3
+ Resolves configuration values from 1Password through the [`op` CLI](https://developer.1password.com/docs/cli/).
4
+
5
+ Secrets are fetched at resolution time. The plugin never persists them, never logs CLI output, and never passes resolved values as command-line arguments. Resolved values do enter the config object returned to your code — treat that object as sensitive.
6
+
7
+ ## Prerequisites
8
+
9
+ - Install the [1Password CLI](https://developer.1password.com/docs/cli/get-started/) and make sure `op` is on `PATH`.
10
+ - Sign in with `op signin`, enable the 1Password app integration, or set `OP_SERVICE_ACCOUNT_TOKEN`. The CLI reads `OP_ACCOUNT`, `OP_CONFIG_DIR`, and `OP_SERVICE_ACCOUNT_TOKEN` on its own — the plugin does not manage auth.
11
+
12
+ ## Setup
13
+
14
+ ```js
15
+ const configorama = require('configorama')
16
+ const createOnePasswordResolver = require('configorama/plugins/onepassword')
17
+
18
+ const opResolver = createOnePasswordResolver({
19
+ refs: {
20
+ npm: 'op://production/npm-automation/notesPlain',
21
+ database: {
22
+ item: 'database-prod',
23
+ vault: 'production',
24
+ field: 'password'
25
+ },
26
+ github: {
27
+ item: 'GitHub',
28
+ vault: 'development',
29
+ section: 'credentials',
30
+ field: 'personal_token'
31
+ },
32
+ copiedLink: 'https://start.1password.com/open/i?a=ACCT&v=VAULT_ID&i=ITEM_ID&h=my.1password.com'
33
+ },
34
+ // account: 'my', // passed to op as --account
35
+ // configDir: '/op/config', // passed to op as --config
36
+ // skipResolution: true, // collect metadata, return placeholders, never call op
37
+ })
38
+
39
+ const config = await configorama('config.yml', {
40
+ variableSources: [opResolver]
41
+ })
42
+ ```
43
+
44
+ Options:
45
+
46
+ | Option | Type | Description |
47
+ |--------|------|-------------|
48
+ | `refs` | object | Alias map. Values may be an `op://` secret reference, an item ID/name, a private item link, or `{ item, vault, section, field }` / `{ ref }` / `{ url }` |
49
+ | `account` | string | Passed to `op` as `--account` |
50
+ | `configDir` | string | Passed to `op` as `--config` |
51
+ | `opPath` | string | Path to the `op` binary (defaults to `op` on `PATH`) |
52
+ | `skipResolution` | boolean | Record metadata and return deterministic placeholders without calling `op` |
53
+
54
+ ## Alias syntax (recommended)
55
+
56
+ ```yaml
57
+ npmToken: ${op:npm.NPM_TOKEN}
58
+ dbPassword: ${op:database}
59
+ rawNote: ${op:npm}
60
+ nested: ${op:npm.database.password}
61
+ ```
62
+
63
+ Alias names may contain only letters, numbers, and underscores. Dots separate the alias from a key path.
64
+
65
+ ## Direct function syntax
66
+
67
+ ```yaml
68
+ directItem: ${op(item-id-example).NPM_TOKEN}
69
+ spacedName: ${op(My Database Login).password}
70
+ directRef: ${op(op://vault/item/field)}
71
+ directRefKey: ${op(op://vault/item/notesPlain).NPM_TOKEN}
72
+ privateLink: ${op(https://start.1password.com/open/i?a=ACCT&v=VAULT_ID&i=ITEM_ID&h=my.1password.com).NPM_TOKEN}
73
+ ```
74
+
75
+ The parentheses give the parser a reliable boundary for specs containing dots, slashes, spaces, or query parameters. Prefer aliases for readability and auditability.
76
+
77
+ ### Unsupported syntax
78
+
79
+ - `${op:op://vault/item/field}` — colon syntax is reserved for aliases. Error message points you to `${op(op://vault/item/field)}`.
80
+ - Private links in colon syntax.
81
+ - Public "share with anyone" links (`share.1password.com`) in any position. Use **Copy Private Link** or an `op://` secret reference instead.
82
+
83
+ ## Private item links
84
+
85
+ Links copied with **Copy Private Link** are supported:
86
+
87
+ ```text
88
+ https://start.1password.com/open/i?a=ACCOUNT&v=VAULT_ID&i=ITEM_ID&h=my.1password.com
89
+ onepassword://open/i?a=ACCOUNT&v=VAULT_ID&i=ITEM_ID&h=my.1password.com
90
+ ```
91
+
92
+ The plugin extracts `i` (item ID) and `v` (vault ID) and fetches with `op item get <itemId> --vault <vaultId>`. The `a`/`h` parameters and the raw URL are never stored in metadata. A link without `v` still resolves, with a warning diagnostic — service accounts and duplicate item names may require vault scoping.
93
+
94
+ ## Field selection
95
+
96
+ With an explicit `field` on a ref, the plugin matches against the 1Password field `id`, `label`, and `purpose` (case-insensitive). `section` matches `section.id`/`section.label` and is required when the same label exists in multiple sections.
97
+
98
+ Without a configured `field`, the plugin infers a single secret field:
99
+
100
+ - `notesPlain`/notes, `purpose: PASSWORD`, `type: CONCEALED`, or secret-like labels (`password`, `token`, `api_key`, `secret`, `credential`, `private key`) are candidates
101
+ - username, email, URL, and other metadata fields are ignored
102
+ - exactly one candidate wins; zero or multiple candidates throw an error telling you to set `field` (and `section`)
103
+
104
+ Ambiguity is always an error — the plugin never silently prefers `notesPlain` over `password`.
105
+
106
+ In direct/alias syntax without a configured field, the first key path segment may select a field by name: `${op(My Login).password}` picks the `password` field. If no field matches, the segment is treated as an INI key inside the inferred field: `${op(note-item).NPM_TOKEN}`.
107
+
108
+ ## Structured notes (INI/dotenv)
109
+
110
+ A secure note like:
111
+
112
+ ```ini
113
+ # npm automation token
114
+ NPM_TOKEN=npm_xxx
115
+
116
+ [database]
117
+ password=s3cr3t
118
+ ```
119
+
120
+ resolves as:
121
+
122
+ ```yaml
123
+ npmToken: ${op:npm.NPM_TOKEN} # npm_xxx
124
+ dbPassword: ${op:npm.database.password} # s3cr3t
125
+ rawNote: ${op:npm} # the whole note text, unparsed
126
+ ```
127
+
128
+ `${op:alias}` always returns the raw field text — even when it contains `=`. Parsing only happens when a key path is requested, so tokens, base64, and connection strings are never corrupted.
129
+
130
+ ## skipResolution
131
+
132
+ With `skipResolution: true` the resolver records metadata and returns deterministic placeholders without calling `op`:
133
+
134
+ ```text
135
+ [OP:alias:npm.NPM_TOKEN]
136
+ [OP:secretRef:op://vault/item/field]
137
+ [OP:item:item-id:NPM_TOKEN]
138
+ [OP:privateLink:item-id]
139
+ ```
140
+
141
+ ## Sync usage
142
+
143
+ ```js
144
+ const config = configorama.sync('config.yml', {
145
+ variableSources: [createOnePasswordResolver({ refs })]
146
+ })
147
+ ```
148
+
149
+ Sync mode rebuilds the resolver inside a worker process from JSON-serializable options and always uses the real `op` binary.
150
+
151
+ ## Auth prompts
152
+
153
+ 1Password app-integration auth is per `op` process. The resolver runs the first `op` call alone and queues the rest behind it, so a config referencing many items triggers at most one biometric/authorization prompt per resolution run instead of one per item.
154
+
155
+ The 1Password dialog names the terminal app (OS-attributed, not customizable), so before the first call the resolver prints a context line to stderr in interactive terminals:
156
+
157
+ ```text
158
+ configorama: requesting 3 items from 1Password (expect an authorization prompt)
159
+ ```
160
+
161
+ The hint is TTY-only — silent in CI and pipes.
162
+
163
+ ## Security model
164
+
165
+ - Secrets are fetched from `op` at resolution time. The plugin never writes them to disk, never logs CLI output, and never passes resolved values as command-line arguments. Resolved values **do** enter the config object returned to your code — treat that object as sensitive (don't log it, commit it, or emit it to an untrusted sink).
166
+ - **Direct syntax reads whatever your `op` session can reach.** `${op(op://any-vault/any-item/field)}`, direct item IDs, and private links are not restricted to the aliases in `refs`. If you resolve a config file you do not fully trust while a 1Password session is unlocked, that config can read any secret the session can access. For untrusted config, run with `safeMode: true` (which blocks all custom resolvers) or audit first — `configorama.audit()` flags this resolver as high-severity `remote_secret_read`.
167
+
168
+ ## Safe mode and audit
169
+
170
+ - `safeMode: true` blocks all custom resolvers during resolution, including this one.
171
+ - `configorama.audit()` registers the plugin without fetching secrets and reports a high-severity `remote_secret_read` finding, because this resolver returns secret material.
172
+
173
+ ## Metadata
174
+
175
+ With `returnMetadata: true`, references are collected under `metadata.opReferences` — reference kinds, item/vault IDs, fields, key paths, and config paths. Secret values, full private links, and CLI output are never recorded.
@@ -0,0 +1,126 @@
1
+ /* Selects a field from op item get JSON, explicitly or by inference
2
+ Ambiguity is always an error - never silently prefers one secret field over another */
3
+
4
+ const SECRET_LABELS = new Set(['password', 'token', 'api_key', 'api key', 'secret', 'credential', 'private key'])
5
+ const IGNORED_PURPOSES = new Set(['USERNAME'])
6
+ const IGNORED_TYPES = new Set(['URL'])
7
+ const IGNORED_LABELS = new Set(['username', 'email', 'url', 'website', 'created', 'updated', 'title'])
8
+
9
+ /**
10
+ * @param {object} item - Parsed op item get JSON
11
+ * @returns {string} Display name for error messages
12
+ */
13
+ function itemName(item) {
14
+ return item.title || item.id || 'unknown'
15
+ }
16
+
17
+ /**
18
+ * @param {string|undefined} value - Field attribute
19
+ * @param {string} wanted - Configured name
20
+ * @returns {boolean} Case-insensitive equality
21
+ */
22
+ function matches(value, wanted) {
23
+ return typeof value === 'string' && value.toLowerCase() === wanted.toLowerCase()
24
+ }
25
+
26
+ /**
27
+ * Select a field from an item.
28
+ * With options.field: explicit matching on id/label/purpose (+ section).
29
+ * Without: inference over secret-content candidates; ambiguity throws.
30
+ * @param {object} item - Parsed op item get JSON
31
+ * @param {object} [options] - { field, section }
32
+ * @returns {object} The selected field object
33
+ */
34
+ function selectField(item, options = {}) {
35
+ const fields = item.fields || []
36
+ if (options.field) {
37
+ return selectExplicit(item, fields, options.field, options.section)
38
+ }
39
+ return inferField(item, fields)
40
+ }
41
+
42
+ /**
43
+ * @param {object} item - Item for error messages
44
+ * @param {object[]} fields - item.fields
45
+ * @param {string} wanted - Configured field name
46
+ * @param {string} [section] - Configured section name
47
+ * @returns {object} Matched field
48
+ */
49
+ function selectExplicit(item, fields, wanted, section) {
50
+ const found = trySelectField(item, { field: wanted, section })
51
+ if (!found) {
52
+ throw new Error(`Field "${wanted}" was not found in 1Password item "${itemName(item)}".`)
53
+ }
54
+ return found
55
+ }
56
+
57
+ /**
58
+ * Match a field by name, returning null when nothing matches.
59
+ * Still throws on ambiguity - duplicate labels always need a section.
60
+ * @param {object} item - Parsed op item get JSON
61
+ * @param {object} options - { field, section }
62
+ * @returns {object|null} Matched field or null
63
+ */
64
+ function trySelectField(item, options) {
65
+ const fields = item.fields || []
66
+ const wanted = options.field
67
+ let candidates = fields.filter((field) => {
68
+ return matches(field.id, wanted) || matches(field.label, wanted) || matches(field.purpose, wanted)
69
+ })
70
+
71
+ if (options.section) {
72
+ candidates = candidates.filter((field) => {
73
+ const fieldSection = field.section || {}
74
+ return matches(fieldSection.id, options.section) || matches(fieldSection.label, options.section)
75
+ })
76
+ }
77
+
78
+ if (candidates.length === 0) {
79
+ return null
80
+ }
81
+ if (candidates.length > 1) {
82
+ throw new Error(`1Password item "${itemName(item)}" has multiple fields labeled "${wanted}". Set section explicitly.`)
83
+ }
84
+ return candidates[0]
85
+ }
86
+
87
+ /**
88
+ * @param {object} field - Item field
89
+ * @returns {boolean} True when the field likely holds secret content
90
+ */
91
+ function isSecretCandidate(field) {
92
+ if (field.value === undefined || field.value === '') return false
93
+ const label = (field.label || '').toLowerCase()
94
+ const purpose = (field.purpose || '').toUpperCase()
95
+ const type = (field.type || '').toUpperCase()
96
+
97
+ if (IGNORED_PURPOSES.has(purpose) || IGNORED_TYPES.has(type) || IGNORED_LABELS.has(label)) {
98
+ return false
99
+ }
100
+ if (matches(field.id, 'notesPlain') || matches(field.label, 'notesPlain') || purpose === 'NOTES') return true
101
+ if (purpose === 'PASSWORD') return true
102
+ if (type === 'CONCEALED') return true
103
+ if (SECRET_LABELS.has(label)) return true
104
+ return false
105
+ }
106
+
107
+ /**
108
+ * Infer the single secret-content field or throw.
109
+ * Never prefers notesPlain over password (or vice versa) - two candidates is an error.
110
+ * @param {object} item - Item for error messages
111
+ * @param {object[]} fields - item.fields
112
+ * @returns {object} Inferred field
113
+ */
114
+ function inferField(item, fields) {
115
+ const candidates = fields.filter(isSecretCandidate)
116
+ if (candidates.length === 1) {
117
+ return candidates[0]
118
+ }
119
+ if (candidates.length > 1) {
120
+ const labels = candidates.map((field) => field.label || field.id).join(', ')
121
+ throw new Error(`1Password item "${itemName(item)}" has multiple candidate secret fields: ${labels}. Set field explicitly.`)
122
+ }
123
+ throw new Error(`1Password item "${itemName(item)}" has no obvious secret field. Set field explicitly.`)
124
+ }
125
+
126
+ module.exports = { selectField, trySelectField }
@@ -0,0 +1,345 @@
1
+ /* 1Password variable source for configorama
2
+ Resolves ${op:alias} and ${op(item)} references through the op CLI */
3
+ const { readSecretRef, getItem } = require('./op-cli')
4
+ const { validateAliasName, normalizeRefValue } = require('./normalize')
5
+ const { selectField, trySelectField } = require('./fields')
6
+ const { parseStructuredSecret, getKeyPath } = require('./parser')
7
+
8
+ const OP_PREFIX = 'op'
9
+
10
+ /**
11
+ * Tell interactive users why an authorization prompt is about to appear,
12
+ * naming what is being fetched. The 1Password dialog only names the terminal
13
+ * app (OS-attributed), so this line supplies the configorama context. Labels
14
+ * are config keys / aliases / fields — references, never secret values.
15
+ * TTY-only: silent in CI and pipes.
16
+ * @param {string[]} labels - Human labels for the values being fetched
17
+ * @param {string} programName - Host tool name shown as the message prefix
18
+ */
19
+ function logAuthHint(labels, programName) {
20
+ if (!process.stderr.isTTY) return
21
+ const unique = [...new Set(labels)]
22
+ const n = unique.length
23
+ const what = n > 0 && n <= 8 ? unique.join(', ') : `${n} value${n === 1 ? '' : 's'}`
24
+ process.stderr.write(`${programName}: fetching ${what} from 1Password (expect an authorization prompt)\n`)
25
+ }
26
+ // Supports: op:alias, op:alias.KEY, op(item), op(item).KEY
27
+ const opVariableSyntax = /^op(?::|\()/
28
+
29
+ /**
30
+ * Creates a 1Password variable source resolver.
31
+ *
32
+ * Syntax:
33
+ * ${op:alias} — alias from refs, raw selected field
34
+ * ${op:alias.KEY} — alias, INI/dotenv key path into the field
35
+ * ${op(spec)} — direct item ID/name, op:// ref, or private link
36
+ * ${op(spec).KEY} — direct spec with key path
37
+ *
38
+ * Colon syntax is reserved for aliases; raw op:// refs and links must use
39
+ * function syntax so the parser has a reliable spec boundary.
40
+ *
41
+ * @param {object} options - Configuration options
42
+ * @param {object} [options.refs] - Alias map: name -> string ref, item name, private link, or { item, vault, section, field } / { ref } / { url }
43
+ * @param {string} [options.account] - Passed to op as --account
44
+ * @param {string} [options.configDir] - Passed to op as --config
45
+ * @param {string} [options.opPath] - Path to the op binary (defaults to "op" on PATH)
46
+ * @param {string} [options.programName] - Host tool name for the auth-prompt hint (defaults to $CONFIGORAMA_PROGRAM_NAME or "configorama")
47
+ * @param {boolean} [options.skipResolution] - Collect metadata and return placeholders without calling op
48
+ * @param {Function} [options.execFile] - execFile injection for tests (not serializable; unavailable in sync mode)
49
+ * @returns {object} Variable source configuration with resolver and metadata collector
50
+ */
51
+ function createOnePasswordResolver(options = {}) {
52
+ const {
53
+ refs = {},
54
+ account,
55
+ configDir,
56
+ opPath,
57
+ programName,
58
+ skipResolution = false,
59
+ execFile,
60
+ } = options
61
+
62
+ const aliasRefs = {}
63
+ for (const alias of Object.keys(refs)) {
64
+ validateAliasName(alias)
65
+ aliasRefs[alias] = normalizeRefValue(refs[alias])
66
+ }
67
+
68
+ const opReferences = []
69
+ // Caches hold in-flight promises so concurrent references to the same
70
+ // item/ref share one op call. Failed promises are evicted immediately,
71
+ // so auth/not-found/parse failures are never cached.
72
+ const secretRefCache = new Map()
73
+ const itemCache = new Map()
74
+
75
+ const cliOptions = { account, configDir, opPath, execFile }
76
+ const scopeKey = `${account || ''}|${configDir || ''}`
77
+
78
+ /**
79
+ * @param {Map} cache - Promise cache
80
+ * @param {string} key - Cache key
81
+ * @param {Function} fn - Producer returning a promise
82
+ * @param {string} [label] - Human label for the auth hint
83
+ * @returns {Promise<*>} Shared promise
84
+ */
85
+ function cached(cache, key, fn, label) {
86
+ if (cache.has(key)) {
87
+ return cache.get(key)
88
+ }
89
+ const promise = withColdStartLatch(fn, label).catch((err) => {
90
+ cache.delete(key)
91
+ throw err
92
+ })
93
+ cache.set(key, promise)
94
+ return promise
95
+ }
96
+
97
+ // 1Password app-integration auth prompts once per op PROCESS until a
98
+ // session exists. Parallel resolution of N distinct items would spawn N
99
+ // processes at cold start and trigger N biometric prompts, so the first
100
+ // op call runs alone; everything else queues behind its settlement and
101
+ // then fans out in parallel against the authorized session.
102
+ let coldStartCall = null
103
+ let hintScheduled = false
104
+ const pendingLabels = []
105
+
106
+ /**
107
+ * @param {Function} fn - Producer returning a promise
108
+ * @param {string} [label] - Human label for the auth hint
109
+ * @returns {Promise<*>} Producer result, gated behind the first call
110
+ */
111
+ function withColdStartLatch(fn, label) {
112
+ if (label) pendingLabels.push(label)
113
+ if (!hintScheduled) {
114
+ hintScheduled = true
115
+ // setImmediate lets the whole parallel fan-out register first so the
116
+ // hint names every value being fetched. The prefix is read here (not at
117
+ // factory time) so a host tool can set CONFIGORAMA_PROGRAM_NAME after the
118
+ // resolver is constructed but before resolution runs.
119
+ setImmediate(() => {
120
+ const prefix = programName || process.env.CONFIGORAMA_PROGRAM_NAME || 'configorama'
121
+ logAuthHint(pendingLabels, prefix)
122
+ })
123
+ }
124
+ if (!coldStartCall) {
125
+ coldStartCall = fn()
126
+ return coldStartCall
127
+ }
128
+ return coldStartCall.catch(() => {}).then(fn)
129
+ }
130
+
131
+ /**
132
+ * Parse an op variable string into its reference and key path.
133
+ * @param {string} varString - e.g. "op:npm.NPM_TOKEN" or "op(item-id).KEY"
134
+ * @returns {{reference: object, keyPath: string|undefined, alias: string|undefined}} Parsed parts
135
+ */
136
+ function parseVariable(varString) {
137
+ const trimmed = varString.trim()
138
+
139
+ const funcMatch = trimmed.match(/^op\((.*)\)(?:\.(.+))?$/)
140
+ if (funcMatch) {
141
+ const spec = funcMatch[1].trim()
142
+ if (!spec) {
143
+ throw new Error(`Invalid 1Password reference "${varString}". Expected \${op(item)}, \${op(op://vault/item/field)}, or a private link.`)
144
+ }
145
+ return { reference: normalizeRefValue(spec), keyPath: funcMatch[2], alias: undefined }
146
+ }
147
+
148
+ // Bare 1Password secret reference: ${op://vault/item/field}. This is the
149
+ // native op:// URI and is treated as a direct secret ref (op read). Key
150
+ // paths are not supported here because op:// refs contain dots and slashes;
151
+ // use alias or function syntax for a structured-note key path.
152
+ if (trimmed.startsWith('op://')) {
153
+ return { reference: { kind: 'secretRef', ref: trimmed }, keyPath: undefined, alias: undefined }
154
+ }
155
+
156
+ if (!trimmed.startsWith('op:')) {
157
+ throw new Error(`Invalid 1Password variable "${varString}".`)
158
+ }
159
+ const rest = trimmed.slice(3)
160
+ if (rest.startsWith('op://')) {
161
+ throw new Error(`Use \${op(${rest})} for direct secret references.`)
162
+ }
163
+ // A private link (or any URL) after the colon is a common mistake — colon
164
+ // syntax is alias-only. Point to function syntax without echoing the link
165
+ // (it carries account/host params we treat as sensitive).
166
+ if (/^https?:\/\//.test(rest) || rest.startsWith('onepassword://')) {
167
+ throw new Error('1Password private links are not supported in colon syntax. Use function syntax: ${op(<private link>)}, or an op:// secret reference.')
168
+ }
169
+
170
+ const dotIndex = rest.indexOf('.')
171
+ const alias = dotIndex === -1 ? rest : rest.slice(0, dotIndex)
172
+ const keyPath = dotIndex === -1 ? undefined : rest.slice(dotIndex + 1)
173
+
174
+ validateAliasName(alias)
175
+ const reference = aliasRefs[alias]
176
+ if (!reference) {
177
+ throw new Error(`Unknown 1Password alias "${alias}". Configure refs.${alias}.`)
178
+ }
179
+ return { reference, keyPath, alias }
180
+ }
181
+
182
+ /**
183
+ * @param {object} params - { reference, keyPath, alias }
184
+ * @returns {string} Deterministic placeholder (never links or secrets)
185
+ */
186
+ function placeholderFor({ reference, keyPath, alias }) {
187
+ if (alias) {
188
+ return `[OP:alias:${alias}${keyPath ? `.${keyPath}` : ''}]`
189
+ }
190
+ if (reference.kind === 'secretRef') {
191
+ return `[OP:secretRef:${reference.ref}]`
192
+ }
193
+ if (reference.kind === 'privateLink') {
194
+ return `[OP:privateLink:${reference.item}]`
195
+ }
196
+ return `[OP:item:${reference.item}${keyPath ? `:${keyPath}` : ''}]`
197
+ }
198
+
199
+ /**
200
+ * Fetch and select the backing field value for a normalized reference.
201
+ * For items without a configured field, the first key path segment may
202
+ * select a field directly: ${op(My Login).password} picks the password
203
+ * field, while ${op(note-item).NPM_TOKEN} falls back to inference and
204
+ * treats NPM_TOKEN as an INI key inside the inferred field.
205
+ * @param {object} reference - Normalized reference
206
+ * @param {string|undefined} keyPath - Requested key path
207
+ * @param {string} [label] - Human label for the auth hint
208
+ * @returns {Promise<{value: string, fieldName: string, remainingKeyPath: string|undefined}>} Selected value
209
+ */
210
+ async function fetchValue(reference, keyPath, label) {
211
+ if (reference.kind === 'secretRef') {
212
+ const value = await cached(secretRefCache, `${scopeKey}|${reference.ref}`, () => {
213
+ return readSecretRef(reference.ref, cliOptions)
214
+ }, label)
215
+ return { value, fieldName: reference.ref, remainingKeyPath: keyPath }
216
+ }
217
+
218
+ const vault = reference.vault
219
+ const itemKey = `${scopeKey}|${vault || ''}|${reference.item}`
220
+ const item = await cached(itemCache, itemKey, () => {
221
+ return getItem(reference.item, { ...cliOptions, vault })
222
+ }, label)
223
+
224
+ // Field selection is a pure function over the cached item JSON, so it
225
+ // needs no cache of its own - no op call is ever saved by one.
226
+ if (reference.field) {
227
+ const field = selectField(item, { field: reference.field, section: reference.section })
228
+ return { value: field.value, fieldName: field.label || field.id, remainingKeyPath: keyPath }
229
+ }
230
+
231
+ if (keyPath !== undefined) {
232
+ const dotIndex = keyPath.indexOf('.')
233
+ const firstSegment = dotIndex === -1 ? keyPath : keyPath.slice(0, dotIndex)
234
+ const fieldMatch = trySelectField(item, { field: firstSegment })
235
+ if (fieldMatch) {
236
+ const remaining = dotIndex === -1 ? undefined : keyPath.slice(dotIndex + 1)
237
+ return { value: fieldMatch.value, fieldName: fieldMatch.label || fieldMatch.id, remainingKeyPath: remaining }
238
+ }
239
+ }
240
+
241
+ const field = selectField(item, {})
242
+ return { value: field.value, fieldName: field.label || field.id, remainingKeyPath: keyPath }
243
+ }
244
+
245
+ /**
246
+ * @param {string} varString - Variable body without ${}
247
+ * @param {object} opts - Resolver options from core (unused)
248
+ * @param {object} currentObject - Config object being populated (unused)
249
+ * @param {object} valueObject - Core value context ({ originalSource, path })
250
+ * @returns {Promise<string>} Resolved value or placeholder
251
+ */
252
+ async function resolver(varString, opts, currentObject, valueObject) {
253
+ const parsed = parseVariable(varString)
254
+ const { reference, keyPath, alias } = parsed
255
+ const configPath = valueObject && valueObject.path ? valueObject.path.join('.') : undefined
256
+
257
+ // Direct private links put the full URL (with account/host params) in
258
+ // the variable string itself - redact it from opReferences entries.
259
+ const isDirectLink = reference.kind === 'privateLink' && !alias
260
+ const redacted = `\${op(...)${keyPath ? `.${keyPath}` : ''}}`
261
+ const entry = {
262
+ raw: isDirectLink ? redacted : (valueObject ? valueObject.originalSource : `\${${varString}}`),
263
+ resolved: isDirectLink ? redacted : `\${${varString}}`,
264
+ alias,
265
+ referenceKind: reference.kind,
266
+ ref: reference.kind === 'secretRef' ? reference.ref : undefined,
267
+ item: reference.item,
268
+ vault: reference.vault,
269
+ field: reference.field,
270
+ section: reference.section,
271
+ keyPath,
272
+ configPath,
273
+ sensitive: true,
274
+ risk: 'remote_secret_read',
275
+ source: 'remote',
276
+ skipped: skipResolution === true,
277
+ }
278
+ if (reference.warnings && reference.warnings.length) {
279
+ entry.diagnostics = reference.warnings.map((warning) => ({ ...warning, configPath }))
280
+ }
281
+ opReferences.push(entry)
282
+
283
+ if (skipResolution) {
284
+ return placeholderFor(parsed)
285
+ }
286
+
287
+ // Auth-hint label: what the user recognizes — the alias, else the config
288
+ // key being set, else the field. Never a secret value.
289
+ const label = alias
290
+ || (configPath ? configPath.split('.').pop() : undefined)
291
+ || reference.field
292
+ || (reference.kind === 'secretRef' ? reference.ref : reference.item)
293
+ const { value, fieldName, remainingKeyPath } = await fetchValue(reference, keyPath, label)
294
+ if (entry.field === undefined && reference.kind !== 'secretRef') {
295
+ entry.field = fieldName
296
+ }
297
+
298
+ if (remainingKeyPath === undefined) {
299
+ return value
300
+ }
301
+ const parsedValue = parseStructuredSecret(value, { fieldName })
302
+ return getKeyPath(parsedValue, remainingKeyPath, fieldName)
303
+ }
304
+
305
+ return {
306
+ type: OP_PREFIX,
307
+ source: 'remote',
308
+ prefix: OP_PREFIX,
309
+ syntax: '${op:alias.KEY}, ${op(item).KEY}, or ${op(op://vault/item/field)}',
310
+ description: 'Resolves values from 1Password through the op CLI',
311
+ sensitive: true,
312
+ risk: 'remote_secret_read',
313
+ match: opVariableSyntax,
314
+ resolver,
315
+ metadataKey: 'opReferences',
316
+ collectMetadata: () => opReferences,
317
+ clearCache: () => {
318
+ secretRefCache.clear()
319
+ itemCache.clear()
320
+ opReferences.length = 0
321
+ coldStartCall = null
322
+ hintScheduled = false
323
+ pendingLabels.length = 0
324
+ },
325
+ syncFactory: require.resolve('./sync-factory'),
326
+ syncOptions: buildSyncOptions(),
327
+ }
328
+
329
+ /**
330
+ * @returns {object} JSON-serializable options for the sync worker
331
+ */
332
+ function buildSyncOptions() {
333
+ const syncOptions = { refs, account, configDir, opPath, skipResolution }
334
+ if (execFile) {
335
+ // Functions cannot cross the JSON boundary into the sync worker;
336
+ // flag it so sync-factory can fail loudly instead of silently
337
+ // running the real op binary.
338
+ syncOptions.hasInjectedExecFile = true
339
+ }
340
+ return syncOptions
341
+ }
342
+ }
343
+
344
+ module.exports = createOnePasswordResolver
345
+ module.exports.opVariableSyntax = opVariableSyntax