@awesomate/hosting-mcp
Version:
Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases
280 lines (253 loc) • 12.6 kB
JavaScript
// secret-drop — one-time localhost form for handing secrets to Claude
// without them entering the chat. Values are never printed; only key names.
//
// File mode (default): writes KEY=value into an env file (mode 600).
// node secret-drop.mjs [--file ~/.config/api-keys.env] [--keys A,B]
// Hub mode: posts each key to the Awesomate hub's encrypted app env —
// the same POST /api/my-apps/apps/:id/env route as awesomate_app_set_env,
// authenticated with the user's own PAT from ~/.awesomate/credentials.json.
// node secret-drop.mjs --hub-app <appId> [--env dev|staging|prod] [--keys A,B]
// Common: [--port 0] [--token <hex>] [--ttl 15] [--stay]
import http from 'node:http';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const args = process.argv.slice(2);
const argVal = (name, def) => {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def;
};
const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
const hubAppId = argVal('hub-app', '');
const hubEnv = argVal('env', 'dev');
const hubMode = hubAppId !== '';
const filePath = path.resolve(expandHome(argVal('file', '~/.config/api-keys.env')));
const ttlMs = Number(argVal('ttl', '15')) * 60_000;
const port = Number(argVal('port', '0'));
const token = argVal('token', crypto.randomBytes(16).toString('hex'));
const stay = args.includes('--stay');
const FILE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const HUB_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
const SAFE_VAL_RE = /^[A-Za-z0-9_@%+=:,.\/-]*$/;
const fail = (msg) => { console.error(`secret-drop: ${msg}`); process.exit(1); };
if (hubMode && (!/^[1-9]\d*$/.test(hubAppId))) fail(`--hub-app must be a positive app id, got: ${hubAppId}`);
if (hubMode && !['dev', 'staging', 'prod'].includes(hubEnv)) fail(`--env must be dev|staging|prod, got: ${hubEnv}`);
const normalizeKey = (name) => (hubMode ? name.toUpperCase() : name);
const keyValid = (name) => (hubMode ? HUB_KEY_RE.test(name) : FILE_KEY_RE.test(name));
const expectedKeys = (argVal('keys', '') || '').split(',').map((s) => normalizeKey(s.trim())).filter(Boolean);
for (const k of expectedKeys) {
if (!keyValid(k)) fail(`invalid key name: ${k}`);
}
let hubAuth = null;
if (hubMode) {
let pat = process.env.AWESOMATE_PAT || '';
let base = process.env.AWESOMATE_API_BASE || '';
if (!pat || !base) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.awesomate', 'credentials.json'), 'utf8'));
const prof = raw.profiles
? raw.profiles[process.env.AWESOMATE_ACCOUNT || raw.defaultProfile] || raw
: raw;
pat = pat || prof.pat || '';
base = base || prof.apiBase || '';
} catch { /* handled below */ }
}
if (!pat) fail('no Awesomate PAT found — connect your hosting first (~/.awesomate/credentials.json), or set AWESOMATE_PAT');
hubAuth = { pat, base: (base || 'https://hub.awesomate.ai').replace(/\/+$/, '') };
}
const destinationLabel = hubMode
? `app #${hubAppId} (${hubEnv} environment) — encrypted in your Awesomate hub`
: filePath;
const tokenOk = (t) => {
if (typeof t !== 'string') return false;
const a = Buffer.from(t);
const b = Buffer.from(token);
return a.length === b.length && crypto.timingSafeEqual(a, b);
};
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const page = (title, body) => `<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title><style>
body{background:#1c1917;color:#e7e5e4;font:15px/1.5 -apple-system,system-ui,sans-serif;
display:flex;justify-content:center;padding:48px 16px}
main{width:100%;max-width:560px}
h1{font-size:20px;font-weight:600}
.card{background:#292524;border:1px solid #44403c;border-radius:12px;padding:24px;margin-top:16px}
label{display:block;font-size:13px;color:#a8a29e;margin:14px 0 4px}
input.sec,textarea{width:100%;box-sizing:border-box;background:#1c1917;
color:#e7e5e4;border:1px solid #44403c;border-radius:8px;padding:10px;font:13px ui-monospace,monospace}
textarea{min-height:110px;resize:vertical}
button{margin-top:18px;background:#6366f1;color:#fff;border:0;border-radius:8px;
padding:10px 18px;font-size:14px;font-weight:600;cursor:pointer}
.muted{color:#a8a29e;font-size:13px}
code{background:#1c1917;padding:1px 5px;border-radius:4px;font-size:12.5px}
ul{padding-left:20px}
.ok{color:#4ade80}
.bad{color:#f87171}
</style></head><body><main>${body}</main></body></html>`;
const formPage = () => {
const fields = expectedKeys.map((k) =>
`<label for="k_${k}">${k}</label><input type="password" class="sec" id="k_${k}" name="k_${k}" autocomplete="off" spellcheck="false">`
).join('');
return page('Secret drop', `
<h1>Secret drop</h1>
<p class="muted">Values submitted here go straight to <code>${esc(destinationLabel)}</code>.
They never enter the Claude chat — Claude only sees the key names.</p>
<div class="card"><form method="post" action="/save">
<input type="hidden" name="t" value="${token}">
${fields}
<label for="bulk">${expectedKeys.length ? 'Or paste' : 'Paste'} one or more <code>KEY=value</code> lines</label>
<textarea id="bulk" name="bulk" autocomplete="off" spellcheck="false" placeholder="${hubMode ? 'STRIPE_SECRET_KEY=sk_live_...' : 'OPENROUTER_API_KEY=sk-or-... RESEND_API_KEY=re_...'}"></textarea>
${expectedKeys.length ? `<label style="display:flex;align-items:center;gap:6px;margin-top:10px">
<input type="checkbox" onchange="document.querySelectorAll('.sec').forEach(i=>i.type=this.checked?'text':'password')" style="width:auto"> show values
</label>` : ''}
<button type="submit">Save securely</button>
</form></div>`);
};
function quoteVal(v) {
if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)) return v;
if (SAFE_VAL_RE.test(v)) return v;
return `'${v.replace(/'/g, `'\\''`)}'`;
}
function writeEnv(file, updates) {
fs.mkdirSync(path.dirname(file), { recursive: true });
let lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
while (lines.length && lines[lines.length - 1] === '') lines.pop();
const replaced = new Set();
lines = lines.map((line) => {
const m = line.match(/^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/);
if (m && updates.has(m[2])) {
replaced.add(m[2]);
return `${m[1] || ''}${m[2]}=${quoteVal(updates.get(m[2]))}`;
}
return line;
});
for (const [k, v] of updates) {
if (!replaced.has(k)) lines.push(`${k}=${quoteVal(v)}`);
}
fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 });
fs.chmodSync(file, 0o600);
}
async function hubSet(key, value) {
const res = await fetch(`${hubAuth.base}/api/my-apps/apps/${hubAppId}/env`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${hubAuth.pat}` },
body: JSON.stringify({ key, value, env: hubEnv }),
signal: AbortSignal.timeout(20_000),
});
let detail = '';
try {
const j = await res.json();
detail = String(j.error || j.detail || '').slice(0, 200);
} catch { /* non-JSON body */ }
return { ok: res.ok, status: res.status, detail };
}
function parseSubmission(params) {
const updates = new Map();
const invalid = [];
const add = (rawName, rawVal) => {
const name = normalizeKey(rawName.trim());
const val = rawVal.replace(/\r/g, '').trim();
if (!val) return;
if (keyValid(name)) updates.set(name, val);
else invalid.push(rawName.trim());
};
for (const [k, v] of params) {
if (k.startsWith('k_')) add(k.slice(2), v);
}
for (const line of (params.get('bulk') || '').split('\n')) {
const s = line.replace(/\r$/, '').trim();
if (!s || s.startsWith('#')) continue;
const eq = s.indexOf('=');
if (eq <= 0) continue;
add(s.slice(0, eq).replace(/^export\s+/, ''), s.slice(eq + 1));
}
return { updates, invalid };
}
const server = http.createServer((req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Referrer-Policy', 'no-referrer');
const url = new URL(req.url, 'http://127.0.0.1');
if (req.method === 'GET' && url.pathname === '/') {
if (!tokenOk(url.searchParams.get('t'))) { res.writeHead(403); return res.end('forbidden'); }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(formPage());
}
if (req.method === 'POST' && url.pathname === '/save') {
let body = '';
req.on('data', (c) => {
body += c;
if (body.length > 1_048_576) req.destroy();
});
req.on('end', async () => {
const params = new URLSearchParams(body);
if (!tokenOk(params.get('t'))) { res.writeHead(403); return res.end('forbidden'); }
const { updates, invalid } = parseSubmission(params);
const invalidNote = invalid.length
? `<p class="bad">Skipped invalid key name${invalid.length > 1 ? 's' : ''}: <code>${invalid.map(esc).join(', ')}</code>${hubMode ? ' (letters, digits and underscores only, starting with a letter)' : ''}</p>`
: '';
if (!updates.size) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(page('Secret drop', `<h1>Nothing saved</h1>${invalidNote}
<p class="muted">No valid <code>KEY=value</code> entries found. Go back and try again.</p>`));
}
if (hubMode) {
const results = [];
for (const [key, value] of updates) {
try {
results.push({ key, ...(await hubSet(key, value)) });
} catch (e) {
results.push({ key, ok: false, status: 0, detail: String((e && e.message) || e).slice(0, 200) });
}
}
const saved = results.filter((r) => r.ok).map((r) => r.key);
const failed = results.filter((r) => !r.ok);
if (saved.length) console.log(`SAVED: ${saved.join(',')} -> hub app ${hubAppId} (${hubEnv})`);
for (const f of failed) console.log(`FAILED: ${f.key} (status ${f.status}${f.detail ? `: ${f.detail}` : ''})`);
const allOk = failed.length === 0;
const rows = results.map((r) => r.ok
? `<li class="ok">✓ ${esc(r.key)}</li>`
: `<li class="bad">✗ ${esc(r.key)} — ${r.status ? `error ${r.status}` : 'request failed'}${r.detail ? `: ${esc(r.detail)}` : ''}</li>`
).join('');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(page('Secret drop', `<h1 class="${allOk ? 'ok' : 'bad'}">${allOk ? 'Saved' : 'Partly saved'}</h1>
${invalidNote}<ul>${rows}</ul>
<p class="muted">Destination: <code>${esc(destinationLabel)}</code>.
${allOk && !stay ? 'You can close this tab — the link is now dead.' : 'Go back to retry the failed keys.'}</p>`));
if (allOk && !stay) setTimeout(() => { server.close(); process.exit(0); }, 750);
return;
}
try {
writeEnv(filePath, updates);
} catch (e) {
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(page('Secret drop', `<h1>Write failed</h1><p class="muted">${esc(String((e && e.message) || e))}</p>`));
}
const names = [...updates.keys()];
console.log(`SAVED: ${names.join(',')} -> ${filePath}`);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(page('Secret drop', `<h1 class="ok">Saved</h1>${invalidNote}
<p>Wrote <strong>${names.map(esc).join(', ')}</strong> to <code>${esc(filePath)}</code>.</p>
<p class="muted">You can close this tab${stay ? '' : ' — the link is now dead'}.</p>`));
if (!stay) setTimeout(() => { server.close(); process.exit(0); }, 750);
});
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(port, '127.0.0.1', () => {
const p = server.address().port;
console.log('secret-drop listening (127.0.0.1 only)');
console.log(` dest: ${destinationLabel}`);
if (expectedKeys.length) console.log(` keys: ${expectedKeys.join(', ')}`);
console.log(` open: http://127.0.0.1:${p}/?t=${token}`);
console.log(` expires in ${Math.round(ttlMs / 60000)} min${stay ? '' : '; exits after first successful save'}`);
});
setTimeout(() => {
console.log('secret-drop: expired');
process.exit(0);
}, ttlMs);