@papervault/cli 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 +21 -0
- package/README.md +212 -0
- package/package.json +59 -0
- package/src/audit.js +52 -0
- package/src/browser.js +24 -0
- package/src/cli.js +42 -0
- package/src/commands/backup.js +217 -0
- package/src/commands/init.js +508 -0
- package/src/commands/sources.js +25 -0
- package/src/commands/verify.js +71 -0
- package/src/config.js +69 -0
- package/src/index.js +10 -0
- package/src/kit-runner.js +72 -0
- package/src/server.js +128 -0
- package/src/sources/azure.js +151 -0
- package/src/sources/file.js +58 -0
- package/src/sources/index.js +40 -0
- package/src/sources/stdin.js +46 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shared kit-generation flow: createKit → optional disk save → optional
|
|
2
|
+
// ephemeral print server. Reused by `backup` (after fetching from a source)
|
|
3
|
+
// and by `init` (after manual entry or .env import).
|
|
4
|
+
//
|
|
5
|
+
// Keeps the in-memory-only posture consistent: secrets only touch disk if
|
|
6
|
+
// the caller explicitly opts in via savePath.
|
|
7
|
+
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { createKit } from '@papervault/core';
|
|
11
|
+
|
|
12
|
+
import { servePrintKit } from './server.js';
|
|
13
|
+
import { openUrl } from './browser.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {Array<object>} opts.secrets Structured secrets array
|
|
18
|
+
* @param {string} [opts.freeText]
|
|
19
|
+
* @param {string} opts.vaultName
|
|
20
|
+
* @param {number} opts.threshold
|
|
21
|
+
* @param {number} opts.shares
|
|
22
|
+
* @param {string[]} [opts.custodianNames]
|
|
23
|
+
* @param {string} [opts.savePath] If set, also writes HTML to <savePath>/vault-<id>/
|
|
24
|
+
* @param {boolean} [opts.print=true] If true, opens the ephemeral print server in the browser
|
|
25
|
+
* @returns {Promise<{vaultId: string, savedTo: string|null, printed: boolean}>}
|
|
26
|
+
*/
|
|
27
|
+
export async function runKitFlow(opts) {
|
|
28
|
+
const {
|
|
29
|
+
secrets, freeText, vaultName, threshold, shares,
|
|
30
|
+
custodianNames, savePath, print = true,
|
|
31
|
+
} = opts;
|
|
32
|
+
|
|
33
|
+
const kit = await createKit({
|
|
34
|
+
secrets,
|
|
35
|
+
freeText,
|
|
36
|
+
vaultName,
|
|
37
|
+
threshold,
|
|
38
|
+
shares,
|
|
39
|
+
custodianNames,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Optional disk save (opt-in). Saved files have the auto-print script
|
|
43
|
+
// stripped — they're for archival, not for the immediate dialog.
|
|
44
|
+
let savedTo = null;
|
|
45
|
+
if (savePath) {
|
|
46
|
+
savedTo = resolve(savePath, `vault-${kit.vaultId}`);
|
|
47
|
+
await mkdir(savedTo, { recursive: true });
|
|
48
|
+
for (const page of kit.pages) {
|
|
49
|
+
const html = page.html.replace(
|
|
50
|
+
/<script>window\.addEventListener\('load',.*?<\/script>/g, ''
|
|
51
|
+
);
|
|
52
|
+
await writeFile(join(savedTo, `${page.filename}.html`), html, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
process.stderr.write(`\nSaved ${kit.pages.length} files to ${savedTo}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!print) {
|
|
58
|
+
if (!savedTo) {
|
|
59
|
+
process.stderr.write('Note: print=false and no savePath means the kit was generated but discarded.\n');
|
|
60
|
+
}
|
|
61
|
+
return { vaultId: kit.vaultId, savedTo, printed: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { url, done } = await servePrintKit(kit);
|
|
65
|
+
process.stderr.write('\nPaperVault kit ready in memory.\n');
|
|
66
|
+
process.stderr.write(`Opening browser: ${url}\n`);
|
|
67
|
+
process.stderr.write('(Click "Done" in the browser when finished to stop the server and exit.)\n');
|
|
68
|
+
openUrl(url);
|
|
69
|
+
await done;
|
|
70
|
+
process.stderr.write('Server stopped. Kit references dropped. Exiting.\n');
|
|
71
|
+
return { vaultId: kit.vaultId, savedTo, printed: true };
|
|
72
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Ephemeral localhost print server.
|
|
2
|
+
// Serves the selector page + each kit page from memory. Binds to 127.0.0.1
|
|
3
|
+
// on a random port. Shuts down on POST /__shutdown or after an idle timeout.
|
|
4
|
+
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { generateSelectorPage } from '@papervault/core';
|
|
7
|
+
|
|
8
|
+
const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes — server self-destructs if user wanders off.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('@papervault/core').Kit} kit
|
|
12
|
+
* @returns {Promise<{url: string, done: Promise<void>}>}
|
|
13
|
+
*/
|
|
14
|
+
export function servePrintKit(kit) {
|
|
15
|
+
// Build the in-memory route map. Each page gets a path like /pages/0, /pages/1...
|
|
16
|
+
const routes = new Map();
|
|
17
|
+
const pageList = kit.pages.map((p, i) => ({
|
|
18
|
+
kind: p.kind,
|
|
19
|
+
path: `/pages/${i}`,
|
|
20
|
+
label: p.label,
|
|
21
|
+
seq: p.seq,
|
|
22
|
+
alias: p.alias,
|
|
23
|
+
custodian: p.custodian,
|
|
24
|
+
}));
|
|
25
|
+
kit.pages.forEach((p, i) => routes.set(`/pages/${i}`, p));
|
|
26
|
+
|
|
27
|
+
// The whole-kit printable doc lives at /print-all and is auto-printed.
|
|
28
|
+
let printAllHtml = kit.printAllHtml;
|
|
29
|
+
|
|
30
|
+
const selectorHtml = generateSelectorPage({
|
|
31
|
+
vaultName: kit.vaultName,
|
|
32
|
+
vaultId: kit.vaultId,
|
|
33
|
+
threshold: kit.threshold,
|
|
34
|
+
shares: kit.shares,
|
|
35
|
+
pages: pageList,
|
|
36
|
+
printAllPath: '/print-all',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let resolveDone;
|
|
40
|
+
const done = new Promise(resolve => { resolveDone = resolve; });
|
|
41
|
+
|
|
42
|
+
const server = createServer((req, res) => {
|
|
43
|
+
// Block any non-localhost requests defensively.
|
|
44
|
+
const remote = req.socket.remoteAddress;
|
|
45
|
+
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') {
|
|
46
|
+
res.writeHead(403); res.end(); return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// CSP: deny everything except inline (we control all the HTML).
|
|
50
|
+
// No external network, no scripts from elsewhere, no images outside data: URIs.
|
|
51
|
+
res.setHeader('Content-Security-Policy',
|
|
52
|
+
"default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
|
|
53
|
+
"script-src 'self' 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'");
|
|
54
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
55
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
56
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
57
|
+
|
|
58
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
59
|
+
const path = url.pathname;
|
|
60
|
+
|
|
61
|
+
if (req.method === 'POST' && path === '/__shutdown') {
|
|
62
|
+
res.writeHead(204); res.end();
|
|
63
|
+
shutdown();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (req.method !== 'GET') {
|
|
67
|
+
res.writeHead(405); res.end(); return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (path === '/' || path === '/index.html') {
|
|
71
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
72
|
+
res.end(selectorHtml);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (path === '/print-all') {
|
|
77
|
+
if (!printAllHtml) {
|
|
78
|
+
res.writeHead(410); res.end('Kit cleared'); return;
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
+
res.end(printAllHtml);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const page = routes.get(path);
|
|
86
|
+
if (page) {
|
|
87
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
88
|
+
res.end(page.html);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
res.writeHead(404); res.end('Not found');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function shutdown() {
|
|
96
|
+
// Drop every in-memory reference we hold so the JS engine can GC the
|
|
97
|
+
// kit data. We can't truly zeroize strings in V8 (they're immutable
|
|
98
|
+
// and may live in internal tables until GC runs), but we can at least
|
|
99
|
+
// make sure nothing in our object graph still points at them.
|
|
100
|
+
routes.clear();
|
|
101
|
+
if (Array.isArray(kit.pages)) kit.pages.length = 0;
|
|
102
|
+
if (Array.isArray(kit.keyShares)) kit.keyShares.length = 0;
|
|
103
|
+
kit.cipherKeyHex = '';
|
|
104
|
+
kit.cipherTextHex = '';
|
|
105
|
+
kit.cipherIvHex = '';
|
|
106
|
+
printAllHtml = null;
|
|
107
|
+
// Allow currently-open connections to drain briefly.
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
server.close(() => resolveDone());
|
|
110
|
+
}, 100);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const idleTimer = setTimeout(() => {
|
|
114
|
+
process.stderr.write('Idle timeout — clearing kit and shutting down server.\n');
|
|
115
|
+
shutdown();
|
|
116
|
+
}, IDLE_TIMEOUT_MS);
|
|
117
|
+
// Don't keep the event loop alive solely for the timer.
|
|
118
|
+
idleTimer.unref?.();
|
|
119
|
+
|
|
120
|
+
return new Promise(resolve => {
|
|
121
|
+
// Bind to 127.0.0.1 only.
|
|
122
|
+
server.listen(0, '127.0.0.1', () => {
|
|
123
|
+
const addr = server.address();
|
|
124
|
+
const url = `http://127.0.0.1:${addr.port}/`;
|
|
125
|
+
resolve({ url, done });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Azure Key Vault adapter.
|
|
2
|
+
//
|
|
3
|
+
// Auth strategy: ride the platform's existing credential cache. We never call
|
|
4
|
+
// the Azure login flow ourselves — if the user hasn't run `az login` (or set
|
|
5
|
+
// AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET), we throw with a
|
|
6
|
+
// clear hint. Same posture as the kit pipeline itself: we're a conduit, not
|
|
7
|
+
// a credential store.
|
|
8
|
+
//
|
|
9
|
+
// URI shape: azure-kv://<vault-name>
|
|
10
|
+
// The host part is the KV name; resolved to https://<name>.vault.azure.net.
|
|
11
|
+
// We don't support custom URLs (private endpoints) in the MVP — easy to add
|
|
12
|
+
// later via azure-kv://<name>?host=<custom>.
|
|
13
|
+
//
|
|
14
|
+
// Mapping: every KV secret becomes one PaperVault `apikey` entry with
|
|
15
|
+
// service = secret name, key = value, notes = contentType (if set).
|
|
16
|
+
// Tags are dropped for now to keep payload small. The kind defaults to
|
|
17
|
+
// apikey because that's what most KV secrets are; users who want different
|
|
18
|
+
// kinds per secret can pre-export to file:// and edit.
|
|
19
|
+
//
|
|
20
|
+
// Allowed-by-default: only the secret's LATEST enabled version. Disabled and
|
|
21
|
+
// expired secrets are skipped (logged to stderr).
|
|
22
|
+
|
|
23
|
+
let cachedSDK = null;
|
|
24
|
+
|
|
25
|
+
async function loadAzureSDK(override) {
|
|
26
|
+
// Tests can inject a fake SDK via opts.sdkOverride to avoid a real
|
|
27
|
+
// network round-trip. Production path lazy-imports the real packages so
|
|
28
|
+
// users who never touch Azure don't pay the require-time cost.
|
|
29
|
+
if (override) return override;
|
|
30
|
+
if (!cachedSDK) {
|
|
31
|
+
const kv = await import('@azure/keyvault-secrets');
|
|
32
|
+
const id = await import('@azure/identity');
|
|
33
|
+
cachedSDK = {
|
|
34
|
+
SecretClient: kv.SecretClient,
|
|
35
|
+
DefaultAzureCredential: id.DefaultAzureCredential,
|
|
36
|
+
AzureCliCredential: id.AzureCliCredential,
|
|
37
|
+
ChainedTokenCredential: id.ChainedTokenCredential,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return cachedSDK;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KV_NAME_RE = /^[a-zA-Z0-9-]{3,24}$/;
|
|
44
|
+
|
|
45
|
+
export function createAzureKVSource(vaultName, opts = {}) {
|
|
46
|
+
if (!KV_NAME_RE.test(vaultName)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`azure-kv: vault name "${vaultName}" is invalid. ` +
|
|
49
|
+
`Key Vault names are 3-24 chars of letters, digits, and dashes.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const host = opts.host || `${vaultName}.vault.azure.net`;
|
|
54
|
+
const vaultUrl = `https://${host}`;
|
|
55
|
+
let client = null;
|
|
56
|
+
let propsCache = null;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
uri: `azure-kv://${vaultName}`,
|
|
60
|
+
|
|
61
|
+
async authenticate() {
|
|
62
|
+
const sdk = await loadAzureSDK(opts.sdkOverride);
|
|
63
|
+
// Prefer the Azure CLI cache first — it's what a developer on a
|
|
64
|
+
// laptop almost always has set up. Fall back to the rest of the
|
|
65
|
+
// default chain (env vars, managed identity, etc.) for CI / VM use.
|
|
66
|
+
const credential = new sdk.ChainedTokenCredential(
|
|
67
|
+
new sdk.AzureCliCredential(),
|
|
68
|
+
new sdk.DefaultAzureCredential(),
|
|
69
|
+
);
|
|
70
|
+
client = new sdk.SecretClient(vaultUrl, credential);
|
|
71
|
+
|
|
72
|
+
// Probe: try to fetch the first page of secret properties. This
|
|
73
|
+
// exercises both the credential and the data-plane permissions.
|
|
74
|
+
try {
|
|
75
|
+
const iter = client.listPropertiesOfSecrets().byPage({ maxPageSize: 1 });
|
|
76
|
+
await iter.next();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
client = null;
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Azure KV authentication / permission check failed for ${vaultUrl}.\n` +
|
|
81
|
+
`Tried: AzureCliCredential, then DefaultAzureCredential.\n` +
|
|
82
|
+
`Hint: run 'az login' (and 'az account set --subscription <id>' if needed), or set\n` +
|
|
83
|
+
` AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET for a service principal.\n` +
|
|
84
|
+
`Hint: confirm your identity has 'Key Vault Secrets User' (or get/list) on this vault.\n` +
|
|
85
|
+
`Underlying: ${err.message ?? err}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async list() {
|
|
91
|
+
if (!client) throw new Error('azure-kv: authenticate() must be called first');
|
|
92
|
+
const refs = [];
|
|
93
|
+
const skipped = [];
|
|
94
|
+
// listPropertiesOfSecrets returns each secret's LATEST version's
|
|
95
|
+
// properties, which is what we want. Disabled or expired secrets
|
|
96
|
+
// are filtered out (with a stderr note so the user knows).
|
|
97
|
+
for await (const p of client.listPropertiesOfSecrets()) {
|
|
98
|
+
const enabled = p.enabled !== false;
|
|
99
|
+
const expired = p.expiresOn && p.expiresOn.getTime() < Date.now();
|
|
100
|
+
if (!enabled) { skipped.push(`${p.name} (disabled)`); continue; }
|
|
101
|
+
if (expired) { skipped.push(`${p.name} (expired)`); continue; }
|
|
102
|
+
refs.push({ name: p.name, kind: 'apikey' });
|
|
103
|
+
// Stash properties for fetch() so we don't list twice.
|
|
104
|
+
propsCache ??= new Map();
|
|
105
|
+
propsCache.set(p.name, p);
|
|
106
|
+
}
|
|
107
|
+
if (skipped.length > 0) {
|
|
108
|
+
process.stderr.write(`azure-kv: skipped ${skipped.length} secret(s): ${skipped.join(', ')}\n`);
|
|
109
|
+
}
|
|
110
|
+
return refs;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async fetch(refs) {
|
|
114
|
+
if (!client) throw new Error('azure-kv: authenticate() must be called first');
|
|
115
|
+
|
|
116
|
+
// If the caller didn't pre-filter, fetch metadata for everything.
|
|
117
|
+
if (!refs) {
|
|
118
|
+
refs = await this.list();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const secrets = [];
|
|
122
|
+
for (const r of refs) {
|
|
123
|
+
let val;
|
|
124
|
+
try {
|
|
125
|
+
val = await client.getSecret(r.name);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new Error(`azure-kv: failed to fetch secret "${r.name}" — ${err.message ?? err}`);
|
|
128
|
+
}
|
|
129
|
+
const contentType = val.properties?.contentType;
|
|
130
|
+
secrets.push({
|
|
131
|
+
kind: 'apikey',
|
|
132
|
+
service: r.name,
|
|
133
|
+
key: val.value,
|
|
134
|
+
secret: '',
|
|
135
|
+
notes: contentType ? `contentType: ${contentType}` : '',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
vaultName: `Azure KV: ${vaultName}`,
|
|
140
|
+
secrets,
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async close() {
|
|
145
|
+
// Drop SDK refs so the credential can release any cached tokens
|
|
146
|
+
// and the secret values fall out of the closure.
|
|
147
|
+
client = null;
|
|
148
|
+
propsCache = null;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// file:// adapter — reads a JSON document from disk.
|
|
2
|
+
// Expected schema (also see stdin adapter):
|
|
3
|
+
// {
|
|
4
|
+
// "vaultName": "string", // optional, can be overridden by --name
|
|
5
|
+
// "freeText": "string", // optional
|
|
6
|
+
// "secrets": [
|
|
7
|
+
// {"kind": "password", "name": "...", "username": "...", "value": "...", "notes": "..."},
|
|
8
|
+
// {"kind": "wallet", "name": "...", "seed": "...", "address": "..."},
|
|
9
|
+
// {"kind": "note", "title": "...", "content": "..."},
|
|
10
|
+
// {"kind": "apikey", "service": "...", "key": "...", "secret": "..."},
|
|
11
|
+
// {"kind": "custom", "label": "...", "value": "..."}
|
|
12
|
+
// ]
|
|
13
|
+
// }
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
function refName(s) {
|
|
18
|
+
return s.name ?? s.label ?? s.service ?? s.title ?? '<unnamed>';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createFileSource(pathFromUri) {
|
|
22
|
+
return {
|
|
23
|
+
uri: `file://${pathFromUri}`,
|
|
24
|
+
async authenticate() { /* no auth */ },
|
|
25
|
+
async list() {
|
|
26
|
+
const doc = await readDoc(pathFromUri);
|
|
27
|
+
return doc.secrets.map(s => ({ name: refName(s), kind: s.kind }));
|
|
28
|
+
},
|
|
29
|
+
async fetch(refs) {
|
|
30
|
+
// file:// already loaded everything; filter to the selected refs
|
|
31
|
+
// so the caller can trust doc.secrets matches what they asked for.
|
|
32
|
+
const doc = await readDoc(pathFromUri);
|
|
33
|
+
if (!refs) return doc;
|
|
34
|
+
const wanted = new Set(refs.map(r => r.name));
|
|
35
|
+
return { ...doc, secrets: doc.secrets.filter(s => wanted.has(refName(s))) };
|
|
36
|
+
},
|
|
37
|
+
async close() { /* nothing to clear */ },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readDoc(path) {
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = await readFile(path, 'utf8');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`file source: cannot read "${path}" — ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
let doc;
|
|
49
|
+
try {
|
|
50
|
+
doc = JSON.parse(raw);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`file source: invalid JSON in "${path}" — ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (!doc || !Array.isArray(doc.secrets)) {
|
|
55
|
+
throw new Error('file source: JSON must have a "secrets" array.');
|
|
56
|
+
}
|
|
57
|
+
return doc;
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Source URI dispatcher. New backends register here.
|
|
2
|
+
|
|
3
|
+
import { createFileSource } from './file.js';
|
|
4
|
+
import { createStdinSource } from './stdin.js';
|
|
5
|
+
import { createAzureKVSource } from './azure.js';
|
|
6
|
+
|
|
7
|
+
export function resolveSource(uri) {
|
|
8
|
+
if (uri === '-' || uri === 'stdin' || uri === 'stdin://') {
|
|
9
|
+
return createStdinSource();
|
|
10
|
+
}
|
|
11
|
+
if (uri.startsWith('file://')) {
|
|
12
|
+
return createFileSource(uri.slice('file://'.length));
|
|
13
|
+
}
|
|
14
|
+
if (uri.startsWith('azure-kv://')) {
|
|
15
|
+
const rest = uri.slice('azure-kv://'.length);
|
|
16
|
+
// Allow ?host=... in the future; for MVP everything after / is ignored.
|
|
17
|
+
const [vaultName] = rest.split(/[/?#]/, 1);
|
|
18
|
+
if (!vaultName) {
|
|
19
|
+
throw new Error('azure-kv: URI must include a vault name, e.g. azure-kv://my-vault');
|
|
20
|
+
}
|
|
21
|
+
return createAzureKVSource(vaultName);
|
|
22
|
+
}
|
|
23
|
+
// Other cloud adapters: not yet implemented.
|
|
24
|
+
if (uri.startsWith('aws-sm://') || uri.startsWith('gcp-sm://') ||
|
|
25
|
+
uri.startsWith('vault://') || uri.startsWith('1password://')) {
|
|
26
|
+
throw new Error(`Source "${uri.split('://')[0]}" is on the roadmap but not yet implemented. ` +
|
|
27
|
+
`Use file://path/to/secrets.json, pipe JSON to stdin, or use azure-kv:// for now.`);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unknown source URI: ${uri}. Supported: file://, stdin (-), azure-kv://.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const SUPPORTED_SOURCES = [
|
|
33
|
+
{ scheme: 'file://', status: 'ready', description: 'JSON file on local disk' },
|
|
34
|
+
{ scheme: 'stdin (-)', status: 'ready', description: 'JSON piped via stdin' },
|
|
35
|
+
{ scheme: 'azure-kv://', status: 'ready', description: 'Azure Key Vault (uses `az login` cache)' },
|
|
36
|
+
{ scheme: 'aws-sm://', status: 'roadmap', description: 'AWS Secrets Manager' },
|
|
37
|
+
{ scheme: 'gcp-sm://', status: 'roadmap', description: 'GCP Secret Manager' },
|
|
38
|
+
{ scheme: 'vault://', status: 'roadmap', description: 'HashiCorp Vault' },
|
|
39
|
+
{ scheme: '1password://', status: 'roadmap', description: '1Password' },
|
|
40
|
+
];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// stdin adapter — same JSON schema as file://, read from process.stdin.
|
|
2
|
+
// Used when source URI is "-".
|
|
3
|
+
|
|
4
|
+
function refName(s) {
|
|
5
|
+
return s.name ?? s.label ?? s.service ?? s.title ?? '<unnamed>';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createStdinSource() {
|
|
9
|
+
let cached = null;
|
|
10
|
+
return {
|
|
11
|
+
uri: 'stdin',
|
|
12
|
+
async authenticate() {},
|
|
13
|
+
async list() {
|
|
14
|
+
const doc = await readOnce();
|
|
15
|
+
return doc.secrets.map(s => ({ name: refName(s), kind: s.kind }));
|
|
16
|
+
},
|
|
17
|
+
async fetch(refs) {
|
|
18
|
+
const doc = await readOnce();
|
|
19
|
+
if (!refs) return doc;
|
|
20
|
+
const wanted = new Set(refs.map(r => r.name));
|
|
21
|
+
return { ...doc, secrets: doc.secrets.filter(s => wanted.has(refName(s))) };
|
|
22
|
+
},
|
|
23
|
+
async close() { cached = null; },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function readOnce() {
|
|
27
|
+
if (cached) return cached;
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
30
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
31
|
+
if (!raw.trim()) {
|
|
32
|
+
throw new Error('stdin source: no input received.');
|
|
33
|
+
}
|
|
34
|
+
let doc;
|
|
35
|
+
try {
|
|
36
|
+
doc = JSON.parse(raw);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw new Error(`stdin source: invalid JSON — ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
if (!doc || !Array.isArray(doc.secrets)) {
|
|
41
|
+
throw new Error('stdin source: JSON must have a "secrets" array.');
|
|
42
|
+
}
|
|
43
|
+
cached = doc;
|
|
44
|
+
return doc;
|
|
45
|
+
}
|
|
46
|
+
}
|