@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.
- package/LICENSE +201 -0
- package/dist/approvals.d.ts +13 -0
- package/dist/approvals.js +73 -0
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +37 -0
- package/dist/broker.d.ts +31 -0
- package/dist/broker.js +449 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +148 -0
- package/dist/guard.d.ts +66 -0
- package/dist/guard.js +157 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/manifest.d.ts +24 -0
- package/dist/manifest.js +165 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +87 -0
- package/dist/presence.d.ts +20 -0
- package/dist/presence.js +58 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +338 -0
- package/dist/sinks/cli-sink-base.d.ts +88 -0
- package/dist/sinks/cli-sink-base.js +217 -0
- package/dist/sinks/doppler.d.ts +45 -0
- package/dist/sinks/doppler.js +198 -0
- package/dist/sinks/dotenv.d.ts +57 -0
- package/dist/sinks/dotenv.js +407 -0
- package/dist/sinks/keychain.d.ts +21 -0
- package/dist/sinks/keychain.js +333 -0
- package/dist/sinks/onepassword.d.ts +58 -0
- package/dist/sinks/onepassword.js +183 -0
- package/dist/sinks/registry.d.ts +4 -0
- package/dist/sinks/registry.js +63 -0
- package/dist/sinks/sops.d.ts +54 -0
- package/dist/sinks/sops.js +254 -0
- package/dist/sinks/types.d.ts +10 -0
- package/dist/sinks/types.js +2 -0
- package/dist/sinks/vault.d.ts +41 -0
- package/dist/sinks/vault.js +156 -0
- package/dist/tickets.d.ts +49 -0
- package/dist/tickets.js +179 -0
- package/dist/validation-state.d.ts +33 -0
- package/dist/validation-state.js +48 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +133 -0
- 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,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
|