myapi-vault-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/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # myapi-vault-cli
2
+
3
+ One-command access to your self-hosted **MyApi** credential vault from any machine. Browser login, ASC-signed requests (bypass per-device approval), and simple `get`/`put` of secrets — no manual env vars.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g myapi-vault-cli
9
+ ```
10
+
11
+ Requires Node ≥ 18. For per-user Cloudflare Access SSO, install [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (optional; a shared service token also works).
12
+
13
+ ## Login
14
+
15
+ ```bash
16
+ myapi login
17
+ ```
18
+
19
+ `login` is **100% browser by default** — no secrets, no env vars:
20
+
21
+ 1. **Cloudflare Access** — opens browser SSO via `cloudflared` (install once: `brew install cloudflared` / [downloads](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)). Use `--cf-token` for a shared service token instead.
22
+ 2. **App token** — a device-flow code you approve in the dashboard (no paste). Use `--paste` to supply an existing token instead.
23
+ 3. **ASC key** — generates + registers an Ed25519 identity so future requests skip per-device approval (approve it once under Devices).
24
+
25
+ Config is cached at `~/.config/myapi/config.json` (mode 600). Approve the ASC device once in your MyApi dashboard when prompted.
26
+
27
+ ## Use
28
+
29
+ ```bash
30
+ myapi whoami # verify identity
31
+ myapi list # list stored entries
32
+ myapi put "OpenAI" "sk-..." https://api.openai.com openai
33
+ myapi get openai # reveal by label/service
34
+ myapi reveal <id> # reveal by id
35
+ myapi del <id> # delete
36
+ myapi logout # clear local config
37
+ ```
38
+
39
+ ## Reading a secret in scripts
40
+
41
+ ```bash
42
+ KEY=$(myapi get openai | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
43
+ ```
44
+
45
+ ## Flags
46
+
47
+ | Flag | Effect |
48
+ |------|--------|
49
+ | `--cf-token` | Use a Cloudflare Access **service token** instead of `cloudflared` browser SSO. |
50
+ | `--paste` | Paste an existing MyApi token instead of the browser device flow. |
51
+
52
+ With `--cf-token`, set the service-token credentials via env (recommended over the prompt):
53
+
54
+ ```bash
55
+ export MYAPI_CF_CLIENT_ID='<...>.access'
56
+ export MYAPI_CF_CLIENT_SECRET='<...>'
57
+ myapi login --cf-token
58
+ ```
59
+
60
+ ## Env
61
+
62
+ - `MYAPI_CONFIG_DIR` — override `~/.config/myapi`.
63
+ - `MYAPI_CF_CLIENT_ID` / `MYAPI_CF_CLIENT_SECRET` — Cloudflare Access service token (for `--cf-token`).
64
+
65
+ ## Security
66
+
67
+ - Your token + ASC key live only in `~/.config/myapi/` (0600). Keep to trusted machines.
68
+ - The MyApi vault is full-access per token; each engineer should use their **own** token (revoke individually).
69
+ - No secrets are printed to logs.
70
+
71
+ ## License
72
+
73
+ MIT
package/bin/myapi.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // myapi — CLI for the self-hosted MyApi credential vault.
3
+ const config = require('../lib/config');
4
+ const vault = require('../lib/vault');
5
+ const { login } = require('../lib/login');
6
+
7
+ const HELP = `myapi — cliente de tu bóveda MyApi
8
+
9
+ Uso:
10
+ myapi login [--paste] [--cf-token] Inicia sesión (100% navegador por defecto)
11
+ myapi whoami Verifica identidad
12
+ myapi list Lista tus claves guardadas
13
+ myapi get <label|service> Revela una clave por nombre/servicio
14
+ myapi reveal <id> Revela una clave por id
15
+ myapi put <name> <secret> <url> [svc] Guarda una clave
16
+ myapi del <id> Borra una clave
17
+ myapi logout Borra la config local
18
+
19
+ Login (por defecto: navegador vía cloudflared + aprobación en el dashboard):
20
+ --cf-token Usa un CF service-token (env/prompt) en vez del SSO de cloudflared
21
+ --paste Pega un token existente en vez del device flow
22
+
23
+ Env: MYAPI_CONFIG_DIR sobreescribe ~/.config/myapi`;
24
+
25
+ function out(x) { console.log(typeof x === 'string' ? x : JSON.stringify(x, null, 2)); }
26
+ function die(m) { console.error(`error: ${m}`); process.exit(1); }
27
+
28
+ async function main() {
29
+ const [cmd, ...rest] = process.argv.slice(2);
30
+ const flags = new Set(rest.filter((a) => a.startsWith('--')));
31
+ const args = rest.filter((a) => !a.startsWith('--'));
32
+
33
+ if (!cmd || cmd === 'help' || flags.has('--help')) return out(HELP);
34
+ if (cmd === 'login') {
35
+ return login({ paste: flags.has('--paste'), cf: flags.has('--cf-token') ? 'service-token' : undefined });
36
+ }
37
+ if (cmd === 'logout') { config.clear(); return out('✓ sesión borrada'); }
38
+
39
+ const cfg = config.load();
40
+ if (!cfg.token) die('no has iniciado sesión — corre: myapi login');
41
+
42
+ switch (cmd) {
43
+ case 'whoami': return out(await vault.whoami(cfg));
44
+ case 'list': return out(await vault.list(cfg));
45
+ case 'get': if (!args[0]) die('uso: myapi get <label|service>'); return out(await vault.get(cfg, args[0]));
46
+ case 'reveal': if (!args[0]) die('uso: myapi reveal <id>'); return out(await vault.reveal(cfg, args[0]));
47
+ case 'put': if (args.length < 3) die('uso: myapi put <name> <secret> <url> [service]'); return out(await vault.put(cfg, args[0], args[1], args[2], args[3]));
48
+ case 'del': if (!args[0]) die('uso: myapi del <id>'); return out(await vault.del(cfg, args[0]));
49
+ default: die(`comando desconocido: ${cmd}\n\n${HELP}`);
50
+ }
51
+ }
52
+
53
+ main().catch((e) => die(e.message));
package/lib/asc.js ADDED
@@ -0,0 +1,33 @@
1
+ // ASC (Agentic Secure Connection): Ed25519 identity that lets signed requests
2
+ // bypass MyApi's per-device approval. Server verifies sign("<ts>:<tokenId>").
3
+ const crypto = require('crypto');
4
+ const os = require('os');
5
+
6
+ // Generate a keypair in the exact shape MyApi expects: a PKCS8 PEM private key
7
+ // and the raw 32-byte Ed25519 public key, base64-encoded.
8
+ function generateKeypair() {
9
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
10
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
11
+ const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
12
+ const pubKeyB64 = spkiDer.subarray(spkiDer.length - 32).toString('base64');
13
+ return { privateKeyPem, pubKeyB64 };
14
+ }
15
+
16
+ // Build the X-Agent-* headers for this instant. Returns {} if no usable key,
17
+ // so the same request path works before ASC is set up (falls back to approval).
18
+ function ascHeaders(asc) {
19
+ if (!asc || !asc.privateKeyPem || !asc.tokenId) return {};
20
+ const ts = String(Math.floor(Date.now() / 1000));
21
+ const sig = crypto.sign(null, Buffer.from(`${ts}:${asc.tokenId}`), crypto.createPrivateKey(asc.privateKeyPem));
22
+ return {
23
+ 'X-Agent-PublicKey': asc.pubKeyB64,
24
+ 'X-Agent-Timestamp': ts,
25
+ 'X-Agent-Signature': sig.toString('base64'),
26
+ };
27
+ }
28
+
29
+ function defaultLabel() {
30
+ return `myapi-cli @ ${os.hostname()}`;
31
+ }
32
+
33
+ module.exports = { generateKeypair, ascHeaders, defaultLabel };
package/lib/cf.js ADDED
@@ -0,0 +1,37 @@
1
+ // Cloudflare Access layer. MyApi sits behind CF Access, so every request must
2
+ // pass the edge. Two modes:
3
+ // - 'cloudflared' : per-user browser SSO; a short-lived JWT via the cloudflared CLI
4
+ // - 'service-token' : static shared Client-Id/Secret headers (admin fallback)
5
+ const { execFileSync } = require('child_process');
6
+
7
+ function cfHeaders(cfg) {
8
+ const cf = cfg.cf || {};
9
+ if (cf.mode === 'service-token') {
10
+ return {
11
+ 'CF-Access-Client-Id': cf.clientId || '',
12
+ 'CF-Access-Client-Secret': cf.clientSecret || '',
13
+ };
14
+ }
15
+ if (cf.mode === 'cloudflared') {
16
+ try {
17
+ const jwt = execFileSync('cloudflared', ['access', 'token', `-app=${cfg.base}`], { encoding: 'utf8' }).trim();
18
+ if (!jwt) throw new Error('empty token');
19
+ return { 'cf-access-token': jwt };
20
+ } catch (e) {
21
+ throw new Error(`cloudflared token failed — run \`cloudflared access login ${cfg.base}\` or \`myapi login\` again. (${String(e.message).split('\n')[0]})`);
22
+ }
23
+ }
24
+ return {};
25
+ }
26
+
27
+ // One-time interactive browser SSO for the Access app.
28
+ function cloudflaredLogin(base) {
29
+ execFileSync('cloudflared', ['access', 'login', base], { stdio: 'inherit' });
30
+ }
31
+
32
+ function hasCloudflared() {
33
+ try { execFileSync('cloudflared', ['--version'], { stdio: 'ignore' }); return true; }
34
+ catch { return false; }
35
+ }
36
+
37
+ module.exports = { cfHeaders, cloudflaredLogin, hasCloudflared };
package/lib/config.js ADDED
@@ -0,0 +1,29 @@
1
+ // Local config for the myapi CLI. Stored at ~/.config/myapi/config.json (0600).
2
+ // Holds: base URL, app token, ASC keypair, and the Cloudflare Access mode.
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const DIR = process.env.MYAPI_CONFIG_DIR || path.join(os.homedir(), '.config', 'myapi');
8
+ const FILE = path.join(DIR, 'config.json');
9
+ const DEFAULT_BASE = 'https://myapi.boxlab.cloud';
10
+
11
+ function load() {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(FILE, 'utf8'));
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ function save(cfg) {
20
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
21
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
22
+ return FILE;
23
+ }
24
+
25
+ function clear() {
26
+ try { fs.unlinkSync(FILE); } catch { /* already gone */ }
27
+ }
28
+
29
+ module.exports = { load, save, clear, FILE, DIR, DEFAULT_BASE };
package/lib/http.js ADDED
@@ -0,0 +1,40 @@
1
+ // Single authenticated request path for every MyApi call.
2
+ // Layers all three auth headers: Cloudflare Access + Bearer token + ASC signature.
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { cfHeaders } = require('./cf');
6
+ const { ascHeaders } = require('./asc');
7
+
8
+ function request(cfg, method, path, bodyObj) {
9
+ const url = new URL(cfg.base + path);
10
+ const headers = { Accept: 'application/json', ...cfHeaders(cfg), ...ascHeaders(cfg.asc) };
11
+ if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
12
+
13
+ let payload;
14
+ if (bodyObj !== undefined) {
15
+ payload = JSON.stringify(bodyObj);
16
+ headers['Content-Type'] = 'application/json';
17
+ headers['Content-Length'] = Buffer.byteLength(payload);
18
+ }
19
+
20
+ const lib = url.protocol === 'http:' ? http : https;
21
+ return new Promise((resolve, reject) => {
22
+ const req = lib.request(
23
+ { hostname: url.hostname, port: url.port || undefined, path: url.pathname + url.search, method, headers },
24
+ (res) => {
25
+ let data = '';
26
+ res.on('data', (c) => { data += c; });
27
+ res.on('end', () => {
28
+ let body;
29
+ try { body = data ? JSON.parse(data) : {}; } catch { body = { raw: data }; }
30
+ resolve({ status: res.statusCode, body });
31
+ });
32
+ }
33
+ );
34
+ req.on('error', reject);
35
+ if (payload) req.write(payload);
36
+ req.end();
37
+ });
38
+ }
39
+
40
+ module.exports = { request };
package/lib/login.js ADDED
@@ -0,0 +1,119 @@
1
+ // Login flows:
2
+ // v1 (paste) : paste an existing MyApi token once; CLI auto-registers ASC.
3
+ // v2 (device flow) : browser approval, no paste; needs the MyApi /device/authorize patch.
4
+ // Both end by registering a fresh ASC key so future requests skip device approval.
5
+ const readline = require('readline');
6
+ const os = require('os');
7
+ const { request } = require('./http');
8
+ const config = require('./config');
9
+ const asc = require('./asc');
10
+ const cf = require('./cf');
11
+
12
+ function prompt(question, hidden = false) {
13
+ return new Promise((resolve) => {
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
15
+ rl.question(question, (answer) => {
16
+ if (hidden) process.stdout.write('\n');
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ });
20
+ if (hidden) rl._writeToOutput = () => {}; // suppress echo of the secret
21
+ });
22
+ }
23
+
24
+ // Generate + register a fresh ASC keypair against the current token.
25
+ // asc/register is device-approval-exempt and returns our own token_id, so this
26
+ // works on a brand-new machine with no prior approval.
27
+ async function registerAsc(cfg) {
28
+ const kp = asc.generateKeypair();
29
+ const reg = await request({ ...cfg, asc: null }, 'POST', '/api/v1/agentic/asc/register', {
30
+ public_key: kp.pubKeyB64,
31
+ label: asc.defaultLabel(),
32
+ });
33
+ if (reg.status >= 300 && reg.status !== 202) {
34
+ throw new Error(`asc/register failed (HTTP ${reg.status}): ${JSON.stringify(reg.body).slice(0, 200)}`);
35
+ }
36
+ const tokenId = reg.body.token_id;
37
+ if (!tokenId) throw new Error('asc/register did not return token_id — MyApi server patch missing');
38
+ const fingerprint = reg.body.key_fingerprint || null;
39
+ const alreadyApproved = reg.body.status === 'already_approved';
40
+ return { ...kp, tokenId, fingerprint, alreadyApproved };
41
+ }
42
+
43
+ // v2: OAuth 2.0 device grant. Requires MyApi to expose /device/authorize publicly.
44
+ async function deviceFlow(cfg) {
45
+ const authz = await request(cfg, 'POST', '/api/v1/agentic/device/authorize', { label: asc.defaultLabel() });
46
+ if (authz.status >= 300) {
47
+ throw new Error(`device flow unavailable (HTTP ${authz.status}) — needs MyApi v2 patch. Use paste login instead.`);
48
+ }
49
+ const a = authz.body;
50
+ const uri = a.verification_uri_complete || a.verification_uri;
51
+ const interval = (a.interval || 5) * 1000;
52
+ const deadline = Date.now() + (a.expires_in || 900) * 1000;
53
+ console.log(`\n Abre en tu navegador: ${uri}`);
54
+ console.log(` Código: ${a.user_code}\n Esperando aprobación…`);
55
+
56
+ while (Date.now() < deadline) {
57
+ await new Promise((r) => setTimeout(r, interval));
58
+ const tok = await request(cfg, 'POST', '/api/v1/agentic/device/token', { device_code: a.device_code });
59
+ const token = tok.body.access_token || tok.body.token || (tok.body.data && tok.body.data.token);
60
+ if (tok.status === 200 && token) return token;
61
+ const err = tok.body && tok.body.error;
62
+ if (err && !/pending|slow_down/i.test(err)) throw new Error(`device/token: ${err}`);
63
+ }
64
+ throw new Error('device flow timed out');
65
+ }
66
+
67
+ async function login(opts = {}) {
68
+ const cfg = config.load();
69
+ cfg.base = opts.base || cfg.base || config.DEFAULT_BASE;
70
+
71
+ // 1) Cloudflare Access — default is browser SSO via cloudflared (no shared secret).
72
+ // --cf-token switches to a static service token (env or prompt).
73
+ if (opts.cf === 'service-token') {
74
+ console.log('→ Cloudflare Access (service token)');
75
+ let clientId = process.env.MYAPI_CF_CLIENT_ID;
76
+ let clientSecret = process.env.MYAPI_CF_CLIENT_SECRET;
77
+ if (!clientId) clientId = await prompt(' CF-Access-Client-Id: ');
78
+ if (!clientSecret) clientSecret = await prompt(' CF-Access-Client-Secret: ', true);
79
+ if (!clientId || !clientSecret) throw new Error('faltan CF-Access-Client-Id/Secret');
80
+ cfg.cf = { mode: 'service-token', clientId: clientId.trim(), clientSecret: clientSecret.trim() };
81
+ } else {
82
+ if (!cf.hasCloudflared()) {
83
+ throw new Error(
84
+ 'Necesitas cloudflared para el login por navegador. Instálalo:\n' +
85
+ ' macOS: brew install cloudflared\n' +
86
+ ' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n' +
87
+ ' (o usa un token de servicio: myapi login --cf-token)'
88
+ );
89
+ }
90
+ console.log(`→ Cloudflare Access: abriendo tu navegador para iniciar sesión (${cfg.base}) …`);
91
+ cf.cloudflaredLogin(cfg.base);
92
+ cfg.cf = { mode: 'cloudflared' };
93
+ }
94
+
95
+ // 2) App token — default is the browser device flow; --paste uses an existing token.
96
+ if (opts.paste) {
97
+ cfg.token = await prompt(' Pega tu token de MyApi: ', true);
98
+ } else {
99
+ console.log('→ Aprobación por navegador (device flow)…');
100
+ cfg.token = await deviceFlow(cfg);
101
+ }
102
+ if (!cfg.token) throw new Error('token vacío');
103
+
104
+ // 3) Auto-ASC (bypass device approval from any machine)
105
+ console.log('→ Registrando llave ASC…');
106
+ cfg.asc = await registerAsc(cfg);
107
+
108
+ const file = config.save(cfg);
109
+ console.log(`\n✓ Sesión guardada en ${file}`);
110
+ if (cfg.asc.alreadyApproved) {
111
+ console.log(' Llave ASC ya aprobada — listo.');
112
+ } else if (cfg.asc.fingerprint) {
113
+ console.log(` Aprueba esta llave 1 vez en el dashboard (${cfg.base}/dashboard → Devices/Approvals):`);
114
+ console.log(` ${cfg.asc.fingerprint}`);
115
+ }
116
+ console.log(' Prueba: myapi whoami');
117
+ }
118
+
119
+ module.exports = { login, registerAsc, deviceFlow };
package/lib/vault.js ADDED
@@ -0,0 +1,47 @@
1
+ // Vault operations. Every endpoint here is master/full-scope only on the server.
2
+ const { request } = require('./http');
3
+
4
+ function ok(r, what) {
5
+ if (r.status >= 200 && r.status < 300) return r.body;
6
+ const msg = (r.body && (r.body.error || r.body.message)) || JSON.stringify(r.body);
7
+ throw new Error(`${what} failed (HTTP ${r.status}): ${msg}`);
8
+ }
9
+
10
+ async function whoami(cfg) {
11
+ const b = ok(await request(cfg, 'GET', '/api/v1/gateway/context'), 'whoami');
12
+ return b.data || b;
13
+ }
14
+
15
+ async function list(cfg) {
16
+ const b = ok(await request(cfg, 'GET', '/api/v1/vault/tokens'), 'list');
17
+ return b.data || b.tokens || b;
18
+ }
19
+
20
+ async function put(cfg, name, secret, url, service) {
21
+ const body = { name, token: secret, websiteUrl: url, ...(service ? { service } : {}) };
22
+ const b = ok(await request(cfg, 'POST', '/api/v1/vault/tokens', body), 'put');
23
+ return b.data || b;
24
+ }
25
+
26
+ async function reveal(cfg, id) {
27
+ const b = ok(await request(cfg, 'GET', `/api/v1/vault/tokens/${encodeURIComponent(id)}/reveal`), 'reveal');
28
+ return b.data || b;
29
+ }
30
+
31
+ async function del(cfg, id) {
32
+ const b = ok(await request(cfg, 'DELETE', `/api/v1/vault/tokens/${encodeURIComponent(id)}`), 'del');
33
+ return b.data || b;
34
+ }
35
+
36
+ // Convenience: find one entry by label/name/service, then reveal its secret.
37
+ async function get(cfg, needle) {
38
+ const items = await list(cfg);
39
+ const n = String(needle).toLowerCase();
40
+ const match = (Array.isArray(items) ? items : []).find(
41
+ (t) => [t.label, t.name, t.service].some((v) => String(v || '').toLowerCase() === n)
42
+ );
43
+ if (!match) throw new Error(`no vault entry matching "${needle}"`);
44
+ return reveal(cfg, match.id);
45
+ }
46
+
47
+ module.exports = { whoami, list, put, reveal, del, get };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "myapi-vault-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for the self-hosted MyApi credential vault: browser login, ASC-signed access, connect from any machine.",
5
+ "bin": {
6
+ "myapi": "bin/myapi.js"
7
+ },
8
+ "type": "commonjs",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "lib",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "myapi",
19
+ "vault",
20
+ "credentials",
21
+ "secrets",
22
+ "cli",
23
+ "asc",
24
+ "cloudflare-access"
25
+ ],
26
+ "author": "emiliovt3",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/emiliovt3/myapi-vault-cli.git"
31
+ }
32
+ }