@envseal/core 0.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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/dist/approvals.d.ts +13 -0
  3. package/dist/approvals.js +73 -0
  4. package/dist/audit.d.ts +42 -0
  5. package/dist/audit.js +37 -0
  6. package/dist/broker.d.ts +31 -0
  7. package/dist/broker.js +449 -0
  8. package/dist/exec.d.ts +18 -0
  9. package/dist/exec.js +148 -0
  10. package/dist/guard.d.ts +66 -0
  11. package/dist/guard.js +157 -0
  12. package/dist/index.d.ts +16 -0
  13. package/dist/index.js +15 -0
  14. package/dist/manifest.d.ts +24 -0
  15. package/dist/manifest.js +165 -0
  16. package/dist/paths.d.ts +14 -0
  17. package/dist/paths.js +87 -0
  18. package/dist/presence.d.ts +20 -0
  19. package/dist/presence.js +58 -0
  20. package/dist/redact.d.ts +20 -0
  21. package/dist/redact.js +338 -0
  22. package/dist/sinks/cli-sink-base.d.ts +88 -0
  23. package/dist/sinks/cli-sink-base.js +217 -0
  24. package/dist/sinks/doppler.d.ts +45 -0
  25. package/dist/sinks/doppler.js +198 -0
  26. package/dist/sinks/dotenv.d.ts +57 -0
  27. package/dist/sinks/dotenv.js +407 -0
  28. package/dist/sinks/keychain.d.ts +21 -0
  29. package/dist/sinks/keychain.js +333 -0
  30. package/dist/sinks/onepassword.d.ts +58 -0
  31. package/dist/sinks/onepassword.js +183 -0
  32. package/dist/sinks/registry.d.ts +4 -0
  33. package/dist/sinks/registry.js +63 -0
  34. package/dist/sinks/sops.d.ts +54 -0
  35. package/dist/sinks/sops.js +254 -0
  36. package/dist/sinks/types.d.ts +10 -0
  37. package/dist/sinks/types.js +2 -0
  38. package/dist/sinks/vault.d.ts +41 -0
  39. package/dist/sinks/vault.js +156 -0
  40. package/dist/tickets.d.ts +49 -0
  41. package/dist/tickets.js +179 -0
  42. package/dist/validation-state.d.ts +33 -0
  43. package/dist/validation-state.js +48 -0
  44. package/dist/verify.d.ts +8 -0
  45. package/dist/verify.js +133 -0
  46. package/package.json +38 -0
@@ -0,0 +1,54 @@
1
+ import type { SecretValue } from '@envseal/protocol';
2
+ import type { ProjectPaths } from '../paths.js';
3
+ import { CliSinkBase } from './cli-sink-base.js';
4
+ import type { CliExecOptions, CliExecResult } from './cli-sink-base.js';
5
+ export declare class SopsSink extends CliSinkBase {
6
+ readonly id = "sops";
7
+ protected readonly requiredCommands: string[];
8
+ protected unavailableReason(): string;
9
+ /**
10
+ * Single choke point for every spawn this sink makes, overridable in tests
11
+ * exactly like vault.ts's — Windows cannot spawn shebang scripts, so the
12
+ * scripted harness substitutes at this seam instead of staging a fake on
13
+ * PATH and leaving argv construction unexercised here.
14
+ */
15
+ protected run(args: readonly string[], options?: CliExecOptions): Promise<CliExecResult>;
16
+ /**
17
+ * sops needs a binary AND an encryption target it can resolve: creation
18
+ * rules from `<root>/.sops.yaml`, or an age recipient passed via --age from
19
+ * ENVSEAL_SOPS_AGE_RECIPIENT. DEcryption additionally needs the matching
20
+ * private key, which sops discovers itself (SOPS_AGE_KEY, SOPS_AGE_KEY_FILE,
21
+ * or its default config location) — unverifiable without attempting an
22
+ * operation, so the honest boundary is: available() says "this sink could
23
+ * store", and a missing identity fails loudly at operation time.
24
+ */
25
+ available(paths: ProjectPaths): Promise<boolean>;
26
+ /**
27
+ * Encrypts the staged plaintext IN PLACE, so the invocation carries only a
28
+ * path — never bytes. With .sops.yaml present its creation rules apply
29
+ * untouched; otherwise --age carries the configured recipient.
30
+ */
31
+ private encryptInPlace;
32
+ /** Decrypts the staged ciphertext IN PLACE; stdout/stderr stay diagnostics-only. */
33
+ private decryptInPlace;
34
+ /**
35
+ * Copies the current sidecar (or starts empty) into a fresh staging dir
36
+ * under .envseal/, runs the operation against the staged path, and removes
37
+ * the directory on every path out — a leaked plaintext temp would outlive
38
+ * the operation's purpose.
39
+ */
40
+ private withStagedSidecar;
41
+ read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
42
+ write(paths: ProjectPaths, key: string, value: SecretValue): Promise<void>;
43
+ remove(paths: ProjectPaths, key: string): Promise<boolean>;
44
+ /**
45
+ * Prerequisite probe plus the encryption-target check, whose absence gets a
46
+ * precise message instead of hiding behind the generic reason. Unlike the
47
+ * other sinks there is no server to authenticate to — what can be cheaply
48
+ * verified is binary + target; the decryption identity is sops' own
49
+ * discovery problem and fails loudly at operation time.
50
+ */
51
+ private requireReady;
52
+ }
53
+ export declare const sopsSink: SopsSink;
54
+ //# sourceMappingURL=sops.d.ts.map
@@ -0,0 +1,254 @@
1
+ import { existsSync, copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join, relative } from 'node:path';
4
+ import { asSecret, SepError } from '@envseal/protocol';
5
+ import { ensureStateDir } from '../paths.js';
6
+ import { CliSinkBase, execCli } from './cli-sink-base.js';
7
+ /**
8
+ * The sidecar this sink owns: a flat `KEY: value` YAML map at the project
9
+ * root whose values are always SOPS ciphertext. A sibling of `.env` rather
10
+ * than a resident of `.envseal/` because the point of the sops sink is an
11
+ * encrypted store that is reviewable and backupable like any artifact — but
12
+ * it must never be COMMITTED by accident (see assertGitSafe below), only
13
+ * deliberately.
14
+ */
15
+ const SIDECAR_NAME = '.env.sealsops.yaml';
16
+ function sidecarPath(paths) {
17
+ return join(paths.root, SIDECAR_NAME);
18
+ }
19
+ /**
20
+ * Same refusal dotenv.ts applies to `.env`, aimed at the sidecar: inside a
21
+ * git work tree the file must be ignored and untracked before any write.
22
+ * Duplicated rather than exported because dotenv's version is private by
23
+ * design and this check is six lines.
24
+ */
25
+ function assertGitSafe(paths) {
26
+ try {
27
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
28
+ cwd: paths.root,
29
+ stdio: 'ignore',
30
+ });
31
+ }
32
+ catch {
33
+ return; // not a git repo — nothing to protect against
34
+ }
35
+ const relPath = relative(paths.root, sidecarPath(paths));
36
+ try {
37
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', relPath], {
38
+ cwd: paths.root,
39
+ stdio: 'ignore',
40
+ });
41
+ throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
42
+ }
43
+ catch (error) {
44
+ if (error instanceof SepError)
45
+ throw error;
46
+ // nonzero from ls-files = not tracked — continue to the ignore check
47
+ }
48
+ try {
49
+ execFileSync('git', ['check-ignore', '-q', relPath], { cwd: paths.root, stdio: 'ignore' });
50
+ }
51
+ catch {
52
+ throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
53
+ }
54
+ }
55
+ /**
56
+ * Parse the flat subset of YAML this sink writes: comments, blank lines, and
57
+ * `KEY: value` where value may be double-quoted. SOPS-encrypted values are
58
+ * single-line scalars (`KEY: ENC[AES256_GCM,data:...,type:str]`) so the rest-
59
+ * of-line rule covers them; multi-line scalars would need a real parser and
60
+ * are neither produced by serializeFlat nor accepted back. Nested blocks —
61
+ * sops writes its metadata under a top-level `sops:` key — are skipped by the
62
+ * key-shape test rather than parsed.
63
+ */
64
+ function parseFlat(yamlText) {
65
+ const map = {};
66
+ for (const line of yamlText.split(/\r?\n/)) {
67
+ const trimmed = line.trim();
68
+ if (trimmed === '' || trimmed.startsWith('#') || trimmed.endsWith(':'))
69
+ continue;
70
+ const colonAt = line.indexOf(':');
71
+ if (colonAt === -1)
72
+ continue;
73
+ const key = line.slice(0, colonAt).trim();
74
+ if (!/^[A-Za-z0-9_-]+$/.test(key))
75
+ continue; // nested keys under sops: etc.
76
+ let value = line.slice(colonAt + 1).trim();
77
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
78
+ value = value.slice(1, -1);
79
+ }
80
+ map[key] = value;
81
+ }
82
+ return map;
83
+ }
84
+ function serializeFlat(map) {
85
+ return (Object.entries(map)
86
+ .map(([key, value]) => `${key}: ${value}`)
87
+ .join('\n') + '\n');
88
+ }
89
+ export class SopsSink extends CliSinkBase {
90
+ id = 'sops';
91
+ requiredCommands = ['sops'];
92
+ unavailableReason() {
93
+ return ('the sops CLI is not installed or no encryption target is configured '
94
+ + '(.sops.yaml creation rules at the project root or ENVSEAL_SOPS_AGE_RECIPIENT)');
95
+ }
96
+ /**
97
+ * Single choke point for every spawn this sink makes, overridable in tests
98
+ * exactly like vault.ts's — Windows cannot spawn shebang scripts, so the
99
+ * scripted harness substitutes at this seam instead of staging a fake on
100
+ * PATH and leaving argv construction unexercised here.
101
+ */
102
+ run(args, options = {}) {
103
+ return execCli('sops', [...args], options);
104
+ }
105
+ /**
106
+ * sops needs a binary AND an encryption target it can resolve: creation
107
+ * rules from `<root>/.sops.yaml`, or an age recipient passed via --age from
108
+ * ENVSEAL_SOPS_AGE_RECIPIENT. DEcryption additionally needs the matching
109
+ * private key, which sops discovers itself (SOPS_AGE_KEY, SOPS_AGE_KEY_FILE,
110
+ * or its default config location) — unverifiable without attempting an
111
+ * operation, so the honest boundary is: available() says "this sink could
112
+ * store", and a missing identity fails loudly at operation time.
113
+ */
114
+ async available(paths) {
115
+ if (!(await super.available(paths)))
116
+ return false;
117
+ return existsSync(join(paths.root, '.sops.yaml')) || process.env.ENVSEAL_SOPS_AGE_RECIPIENT !== undefined;
118
+ }
119
+ /**
120
+ * Encrypts the staged plaintext IN PLACE, so the invocation carries only a
121
+ * path — never bytes. With .sops.yaml present its creation rules apply
122
+ * untouched; otherwise --age carries the configured recipient.
123
+ */
124
+ async encryptInPlace(stagedPath, paths) {
125
+ const args = ['--encrypt', '--input-type', 'yaml', '--output-type', 'yaml'];
126
+ if (!existsSync(join(paths.root, '.sops.yaml'))) {
127
+ const recipient = process.env.ENVSEAL_SOPS_AGE_RECIPIENT;
128
+ if (recipient === undefined) {
129
+ throw new SepError({
130
+ code: 'SEP_SINK_UNAVAILABLE',
131
+ userMessage: `The ${this.id} sink is not available — no .sops.yaml creation rules at the project root and ENVSEAL_SOPS_AGE_RECIPIENT is unset.`,
132
+ });
133
+ }
134
+ args.push('--age', recipient);
135
+ }
136
+ args.push(stagedPath);
137
+ await this.run(args);
138
+ }
139
+ /** Decrypts the staged ciphertext IN PLACE; stdout/stderr stay diagnostics-only. */
140
+ async decryptInPlace(stagedPath) {
141
+ await this.run(['--decrypt', '--input-type', 'yaml', '--output-type', 'yaml', stagedPath]);
142
+ }
143
+ /**
144
+ * Copies the current sidecar (or starts empty) into a fresh staging dir
145
+ * under .envseal/, runs the operation against the staged path, and removes
146
+ * the directory on every path out — a leaked plaintext temp would outlive
147
+ * the operation's purpose.
148
+ */
149
+ async withStagedSidecar(paths, operate) {
150
+ ensureStateDir(paths);
151
+ const stagingDir = mkdtempSync(join(paths.stateDir, 'sops-'));
152
+ const staged = join(stagingDir, 'sidecar.yaml');
153
+ try {
154
+ const file = sidecarPath(paths);
155
+ if (existsSync(file))
156
+ copyFileSync(file, staged);
157
+ else
158
+ writeFileSync(staged, '', { mode: 0o600 });
159
+ await operate(staged);
160
+ }
161
+ finally {
162
+ rmSync(stagingDir, { recursive: true, force: true });
163
+ }
164
+ }
165
+ async read(paths, key) {
166
+ try {
167
+ await this.requireReady(paths);
168
+ const file = sidecarPath(paths);
169
+ if (!existsSync(file))
170
+ return null;
171
+ let map = {};
172
+ await this.withStagedSidecar(paths, async (staged) => {
173
+ await this.decryptInPlace(staged);
174
+ map = parseFlat(readFileSync(staged, 'utf8'));
175
+ });
176
+ const value = map[key];
177
+ if (value === undefined)
178
+ return null;
179
+ return asSecret(Buffer.from(value, 'utf8'));
180
+ }
181
+ catch (error) {
182
+ throw this.sinkFailure('read', error, key);
183
+ }
184
+ }
185
+ async write(paths, key, value) {
186
+ try {
187
+ await this.requireReady(paths);
188
+ assertGitSafe(paths);
189
+ await this.withStagedSidecar(paths, async (staged) => {
190
+ // An existing staged sidecar is ciphertext and must be decrypted back
191
+ // before merging; a first-ever write stages an empty file that sops
192
+ // must never be pointed at.
193
+ if (existsSync(staged) && readFileSync(staged, 'utf8').trim() !== '') {
194
+ await this.decryptInPlace(staged);
195
+ }
196
+ const map = parseFlat(readFileSync(staged, 'utf8'));
197
+ map[key] = value.toString('utf8');
198
+ writeFileSync(staged, serializeFlat(map), { mode: 0o600 });
199
+ await this.encryptInPlace(staged, paths);
200
+ writeFileSync(sidecarPath(paths), readFileSync(staged), { mode: 0o600 });
201
+ });
202
+ }
203
+ catch (error) {
204
+ throw this.sinkFailure('write', error, key);
205
+ }
206
+ }
207
+ async remove(paths, key) {
208
+ try {
209
+ await this.requireReady(paths);
210
+ const file = sidecarPath(paths);
211
+ if (!existsSync(file))
212
+ return false;
213
+ let removed = false;
214
+ await this.withStagedSidecar(paths, async (staged) => {
215
+ await this.decryptInPlace(staged);
216
+ const map = parseFlat(readFileSync(staged, 'utf8'));
217
+ if (!(key in map))
218
+ return;
219
+ delete map[key];
220
+ removed = true;
221
+ if (Object.keys(map).length === 0) {
222
+ // Last entry gone: an encrypted file holding nothing is noise, not a store.
223
+ rmSync(file);
224
+ return;
225
+ }
226
+ writeFileSync(staged, serializeFlat(map), { mode: 0o600 });
227
+ await this.encryptInPlace(staged, paths);
228
+ writeFileSync(file, readFileSync(staged), { mode: 0o600 });
229
+ });
230
+ return removed;
231
+ }
232
+ catch (error) {
233
+ throw this.sinkFailure('remove', error, key);
234
+ }
235
+ }
236
+ /**
237
+ * Prerequisite probe plus the encryption-target check, whose absence gets a
238
+ * precise message instead of hiding behind the generic reason. Unlike the
239
+ * other sinks there is no server to authenticate to — what can be cheaply
240
+ * verified is binary + target; the decryption identity is sops' own
241
+ * discovery problem and fails loudly at operation time.
242
+ */
243
+ async requireReady(paths) {
244
+ await this.requirePrerequisites();
245
+ if (!existsSync(join(paths.root, '.sops.yaml')) && process.env.ENVSEAL_SOPS_AGE_RECIPIENT === undefined) {
246
+ throw new SepError({
247
+ code: 'SEP_SINK_UNAVAILABLE',
248
+ userMessage: `The ${this.id} sink is not available — no .sops.yaml creation rules at the project root and ENVSEAL_SOPS_AGE_RECIPIENT is unset.`,
249
+ });
250
+ }
251
+ }
252
+ }
253
+ export const sopsSink = new SopsSink();
254
+ //# sourceMappingURL=sops.js.map
@@ -0,0 +1,10 @@
1
+ import type { SecretValue } from '@envseal/protocol';
2
+ import type { ProjectPaths } from '../paths.js';
3
+ export interface Sink {
4
+ readonly id: string;
5
+ available(paths: ProjectPaths): Promise<boolean>;
6
+ read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
7
+ write(paths: ProjectPaths, key: string, value: SecretValue): Promise<void>;
8
+ remove(paths: ProjectPaths, key: string): Promise<boolean>;
9
+ }
10
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,41 @@
1
+ import type { SecretValue } from '@envseal/protocol';
2
+ import type { ProjectPaths } from '../paths.js';
3
+ import { CliSinkBase } from './cli-sink-base.js';
4
+ import type { CliExecOptions, CliExecResult } from './cli-sink-base.js';
5
+ /**
6
+ * Auth posture: the token reaches the CLI through its own discovery chain
7
+ * (VAULT_TOKEN or ~/.vault-token); this sink never passes -token=, which
8
+ * would put the credential in argv. Writes need create/update on
9
+ * `<mount>/envseal/*`; reads need read on the same paths.
10
+ */
11
+ export declare class VaultSink extends CliSinkBase {
12
+ readonly id = "vault";
13
+ protected readonly requiredCommands: string[];
14
+ protected unavailableReason(): string;
15
+ /**
16
+ * Single choke point for every spawn this sink makes. Production resolves
17
+ * `vault` off PATH; tests substitute a fake provider here instead of staging
18
+ * one on PATH, because Windows cannot spawn shebang scripts directly and a
19
+ * PATH-only double would leave the parsing/error-mapping logic unexercised
20
+ * there (see vault-sink.test.ts).
21
+ */
22
+ protected run(args: readonly string[], options?: CliExecOptions): Promise<CliExecResult>;
23
+ /**
24
+ * Vault on PATH AND an address to talk to. The base probe deliberately
25
+ * checks binaries only — VAULT_ADDR-style configuration is validated here
26
+ * and at operation time, where its absence can produce a precise message.
27
+ */
28
+ available(_paths: ProjectPaths): Promise<boolean>;
29
+ read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
30
+ write(paths: ProjectPaths, key: string, value: SecretValue): Promise<void>;
31
+ remove(paths: ProjectPaths, key: string): Promise<boolean>;
32
+ /**
33
+ * Every operation re-probes the CLI — available() may have been consulted
34
+ * long before, and the binary can vanish in between — then demands
35
+ * VAULT_ADDR separately, whose absence gets its own precise message instead
36
+ * of hiding behind the generic reason.
37
+ */
38
+ private requireReady;
39
+ }
40
+ export declare const vaultSink: VaultSink;
41
+ //# sourceMappingURL=vault.d.ts.map
@@ -0,0 +1,156 @@
1
+ import { SepError, asSecret } from '@envseal/protocol';
2
+ import { CliSinkBase, execCli, exitCodeOf } from './cli-sink-base.js';
3
+ import { unsafeSecretToUtf8 } from './dotenv.js';
4
+ /**
5
+ * The mount envseal writes into when ENVSEAL_VAULT_MOUNT does not override it.
6
+ * A dedicated mount (kv-v2) keeps project secrets out of whatever else lives
7
+ * under the provider's default namespace.
8
+ */
9
+ const DEFAULT_MOUNT = 'secret';
10
+ /**
11
+ * `vault kv get` reports a missing path as exit 2 ("No value found at ...").
12
+ * Encoded from vault's documented CLI behavior: no vault exists on the
13
+ * development machine this mapping was authored on, so the live round-trip
14
+ * suite in vault-sink.test.ts is what confirms it wherever a server exists.
15
+ */
16
+ const KV_NO_VALUE_FOUND = 2;
17
+ /** The exact stderr phrase vault prints for a missing path. */
18
+ const NO_VALUE_FOUND_MARKER = 'No value found';
19
+ /**
20
+ * Exit 2 alone is NOT absence: the same code carries permission denied and
21
+ * every other API error. Only the "No value found" marker on stderr does —
22
+ * checking it keeps a forbidden path a loud SEP_SINK_WRITE_FAILED instead of
23
+ * a silent null that would send ensure() back to the prompt.
24
+ */
25
+ function documentedAbsence(error) {
26
+ if (exitCodeOf(error) !== KV_NO_VALUE_FOUND)
27
+ return false;
28
+ return (error?.stderr ?? '').includes(NO_VALUE_FOUND_MARKER);
29
+ }
30
+ /** The mount name, resolved per call so tests can flip the override freely. */
31
+ function mountName() {
32
+ return process.env.ENVSEAL_VAULT_MOUNT ?? DEFAULT_MOUNT;
33
+ }
34
+ /**
35
+ * One server-side path PER KEY, scoped by project the way keychain.ts scopes
36
+ * its account names: `envseal/<project-leaf>/<key>`. The key must ride in the
37
+ * path, not sit beside its siblings as a field on a shared path: `vault kv
38
+ * put` REPLACES the entire data map at the target and `kv delete` removes the
39
+ * whole path, so shared-path storage would let every write silently destroy
40
+ * every other key and any single remove destroy them all. Both sinks scope by
41
+ * the leaf directory name of the project root, so two checkouts of one repo
42
+ * stay separate unless they share a basename — the same trade keychain makes.
43
+ */
44
+ function relativePath(paths, key) {
45
+ const projectId = paths.root.split(/[\\/]/).pop() ?? 'unknown';
46
+ return `envseal/${projectId}/${key}`;
47
+ }
48
+ /**
49
+ * Auth posture: the token reaches the CLI through its own discovery chain
50
+ * (VAULT_TOKEN or ~/.vault-token); this sink never passes -token=, which
51
+ * would put the credential in argv. Writes need create/update on
52
+ * `<mount>/envseal/*`; reads need read on the same paths.
53
+ */
54
+ export class VaultSink extends CliSinkBase {
55
+ id = 'vault';
56
+ requiredCommands = ['vault'];
57
+ unavailableReason() {
58
+ // A present-but-unaddressed binary (VAULT_ADDR unset) is indistinguishable
59
+ // from a missing one until an operation runs, so the message names both.
60
+ return 'the vault CLI is not installed or VAULT_ADDR is unset';
61
+ }
62
+ /**
63
+ * Single choke point for every spawn this sink makes. Production resolves
64
+ * `vault` off PATH; tests substitute a fake provider here instead of staging
65
+ * one on PATH, because Windows cannot spawn shebang scripts directly and a
66
+ * PATH-only double would leave the parsing/error-mapping logic unexercised
67
+ * there (see vault-sink.test.ts).
68
+ */
69
+ run(args, options = {}) {
70
+ return execCli('vault', [...args], options);
71
+ }
72
+ /**
73
+ * Vault on PATH AND an address to talk to. The base probe deliberately
74
+ * checks binaries only — VAULT_ADDR-style configuration is validated here
75
+ * and at operation time, where its absence can produce a precise message.
76
+ */
77
+ async available(_paths) {
78
+ return (await super.available(_paths)) && Boolean(process.env.VAULT_ADDR);
79
+ }
80
+ async read(paths, key) {
81
+ await this.requireReady();
82
+ try {
83
+ const { stdout } = await this.run([
84
+ 'kv',
85
+ 'get',
86
+ '-field',
87
+ key,
88
+ '-mount',
89
+ mountName(),
90
+ relativePath(paths, key),
91
+ ]);
92
+ // -field prints the value plus one trailing newline; strip exactly that
93
+ // one, byte-exact like keychain.ts — never a blanket trim, which would
94
+ // corrupt a value that genuinely ends in whitespace. A stored value that
95
+ // itself ends in a literal newline loses it here, the same trade every
96
+ // newline-terminated CLI output in this codebase makes.
97
+ return asSecret(Buffer.from(stdout.replace(/\r?\n$/, ''), 'utf8'));
98
+ }
99
+ catch (error) {
100
+ if (documentedAbsence(error))
101
+ return null;
102
+ throw this.sinkFailure('read', error, key);
103
+ }
104
+ }
105
+ async write(paths, key, value) {
106
+ await this.requireReady();
107
+ try {
108
+ // `<KEY>=-` is vault's documented stdin form (`echo x | vault kv put
109
+ // -mount=secret foo bar=-`): the bytes travel through the inherited
110
+ // pipe until EOF, never argv where any process listing could read them.
111
+ // Exactly one field reads stdin per call, so ordering quirks cannot mix
112
+ // fields. -mount keeps the positional path relative, matching the
113
+ // documented flag rather than baking the mount into the path string.
114
+ await this.run(['kv', 'put', '-mount', mountName(), relativePath(paths, key), `${key}=-`], { input: unsafeSecretToUtf8(value) });
115
+ }
116
+ catch (error) {
117
+ throw this.sinkFailure('write', error, key);
118
+ }
119
+ }
120
+ async remove(paths, key) {
121
+ await this.requireReady();
122
+ try {
123
+ // kv delete (soft-deletes the latest version), never kv destroy: the
124
+ // version history stays intact for audit, matching how the other sinks
125
+ // treat removal as reversible-at-the-provider rather than shredding.
126
+ await this.run(['kv', 'delete', '-mount', mountName(), relativePath(paths, key)]);
127
+ // Current servers report success ("Data deleted (if it existed)...")
128
+ // even when nothing was ever written, so true reflects what the tool
129
+ // actually reported; the absence mapping below is defensive for builds
130
+ // that surface a missing path the way kv get does.
131
+ return true;
132
+ }
133
+ catch (error) {
134
+ if (documentedAbsence(error))
135
+ return false;
136
+ throw this.sinkFailure('remove', error, key);
137
+ }
138
+ }
139
+ /**
140
+ * Every operation re-probes the CLI — available() may have been consulted
141
+ * long before, and the binary can vanish in between — then demands
142
+ * VAULT_ADDR separately, whose absence gets its own precise message instead
143
+ * of hiding behind the generic reason.
144
+ */
145
+ async requireReady() {
146
+ await this.requirePrerequisites();
147
+ if (!process.env.VAULT_ADDR) {
148
+ throw new SepError({
149
+ code: 'SEP_SINK_UNAVAILABLE',
150
+ userMessage: `The ${this.id} sink is not available — VAULT_ADDR is unset.`,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ export const vaultSink = new VaultSink();
156
+ //# sourceMappingURL=vault.js.map
@@ -0,0 +1,49 @@
1
+ import type { TicketKeyOutcome, TicketOutcome } from '@envseal/protocol';
2
+ export type TicketRecordState = 'pending' | 'resolved' | 'expired' | 'cancelled';
3
+ export interface TicketRecord {
4
+ ticket: string;
5
+ nonce: string;
6
+ keys: string[];
7
+ reason: string;
8
+ surface: string;
9
+ createdAt: number;
10
+ expiresAt: number;
11
+ state: TicketRecordState;
12
+ outcomes: Map<string, TicketKeyOutcome>;
13
+ }
14
+ export interface TicketStoreOptions {
15
+ ttlMs?: number;
16
+ sweepIntervalMs?: number;
17
+ }
18
+ export declare class TicketStore {
19
+ private readonly records;
20
+ private readonly waiters;
21
+ private readonly pendingAwaits;
22
+ private readonly timer;
23
+ private readonly defaultTtlMs;
24
+ constructor(options?: TicketStoreOptions);
25
+ create(opts: {
26
+ keys: string[];
27
+ reason: string;
28
+ surface: string;
29
+ ttlMs?: number;
30
+ }): TicketRecord;
31
+ get(ticket: string): TicketRecord | undefined;
32
+ setOutcome(ticket: string, key: string, outcome: TicketKeyOutcome): void;
33
+ resolve(ticket: string): void;
34
+ cancel(ticket: string): void;
35
+ sweep(now?: number): void;
36
+ /**
37
+ * F-W7-6: expiry used to be computed only by the 60s sweep, so an await that
38
+ * outlived a shorter TTL reported `pending` rather than `expired`. Every
39
+ * caller that can observe a record evaluates `expiresAt` itself.
40
+ */
41
+ private expireIfDue;
42
+ await(ticket: string, timeoutMs: number): Promise<TicketOutcome>;
43
+ dispose(): void;
44
+ private toOutcome;
45
+ private subscribe;
46
+ private unsubscribe;
47
+ private bump;
48
+ }
49
+ //# sourceMappingURL=tickets.d.ts.map