mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: encrypted secrets vault across CLI, desktop, dashboard, and backend
Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id → XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:* / inject (primora:// refs) / agent credential broker (dummy tokens in the child, real secret on the wire) / TTL sessions / stack:* operator group. Project vault (backend): core.project_secrets, AES-256-GCM via the platform encryptor. Metadata-only list, audit-logged reveal, and secret://NAME refs resolved at job delivery — refs stored, values never. Surfaces: dashboard Vault page (+ local-vault card under the Tauri shell), desktop vault window via IPC to the primora binary, secrets:* --remote CLI mode, metadata/write-only MCP tools so agents consume secrets via secret:// refs without holding plaintext. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
31bc23386b
commit
585bb04ab2
52 files changed
+3929
-142
No files matched your search
@@ -27,6 +27,7 @@
|
||||
</form>
|
||||
<p id="err" class="err" hidden></p>
|
||||
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
|
||||
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
@@ -51,6 +52,11 @@
|
||||
if (saved) location.replace(saved);
|
||||
}
|
||||
|
||||
document.getElementById("vault-link").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
tauri?.event.emit("open-vault");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
err.hidden = true;
|
||||
|
||||
@@ -99,3 +99,222 @@ button:disabled {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- secrets vault window ---------- */
|
||||
|
||||
body.vault {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
background: #101216;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-sub {
|
||||
font-size: 0.72rem;
|
||||
color: #6d6d76;
|
||||
font-family: ui-monospace, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #6d6d76;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
color: #34d399;
|
||||
border-color: #1e3a2f;
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
color: #9a9aa3;
|
||||
}
|
||||
|
||||
.view {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vcard {
|
||||
background: #14161b;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.vcard h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vcard select {
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: #0b0d11;
|
||||
border: 1px solid #2e3239;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.vtable th {
|
||||
text-align: left;
|
||||
color: #6d6d76;
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
}
|
||||
|
||||
.vtable td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-bottom: 1px solid #1e2025;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vtable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dimcell {
|
||||
color: #9a9aa3;
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #2e3239;
|
||||
color: #c9c9cf;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
border-color: #f4693a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost.sm {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
border-color: #ef6b6b;
|
||||
color: #ef6b6b;
|
||||
}
|
||||
|
||||
.vform {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vform input {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vform input.grow {
|
||||
flex: 3 1 14rem;
|
||||
}
|
||||
|
||||
.vform button {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: #0b0d11;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #e7e7ea;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vlink {
|
||||
color: #f4693a;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.vlink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: #6d6d76;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Primora — Secrets Vault</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="vault">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<img src="icon.svg" alt="" />
|
||||
<div>
|
||||
<h1>Secrets Vault</h1>
|
||||
<p id="path" class="bar-sub"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-right">
|
||||
<span id="pill" class="pill"></span>
|
||||
<button id="lock-btn" class="ghost" hidden>Lock</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="view" class="view">
|
||||
<p class="dim">Loading…</p>
|
||||
</main>
|
||||
<script type="module" src="vault.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,247 @@
|
||||
const tauri = window.__TAURI__;
|
||||
const view = document.getElementById("view");
|
||||
const pill = document.getElementById("pill");
|
||||
const lockBtn = document.getElementById("lock-btn");
|
||||
const pathEl = document.getElementById("path");
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
const el = (tag, attrs = {}, ...children) => {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") node.className = v;
|
||||
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) node.append(c);
|
||||
return node;
|
||||
};
|
||||
|
||||
const errBox = (msg) => el("p", { class: "err" }, msg);
|
||||
const dimNote = (msg) => el("p", { class: "hint" }, msg);
|
||||
|
||||
function setPill(state, expiresAt) {
|
||||
clearInterval(countdownTimer);
|
||||
pill.className = "pill";
|
||||
lockBtn.hidden = true;
|
||||
if (state === "unlocked" && expiresAt) {
|
||||
const tick = () => {
|
||||
const left = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
|
||||
const m = Math.floor(left / 60);
|
||||
const s = left % 60;
|
||||
pill.textContent = `● unlocked — ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
if (!left) refresh();
|
||||
};
|
||||
pill.classList.add("ok");
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
lockBtn.hidden = false;
|
||||
} else {
|
||||
pill.textContent = state === "none" ? "no vault" : "● locked";
|
||||
if (state !== "none") pill.classList.add("warn");
|
||||
}
|
||||
}
|
||||
|
||||
function card(title, sub, ...children) {
|
||||
return el("section", { class: "vcard" },
|
||||
el("h2", {}, title),
|
||||
sub ? el("p", { class: "sub" }, sub) : "",
|
||||
...children);
|
||||
}
|
||||
|
||||
function busy(btn, label) {
|
||||
btn.disabled = true;
|
||||
btn.dataset.label = btn.textContent;
|
||||
btn.textContent = label;
|
||||
}
|
||||
const unbusy = (btn) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label || btn.textContent;
|
||||
};
|
||||
|
||||
/* ---------- views ---------- */
|
||||
|
||||
function renderMissing() {
|
||||
setPill("none");
|
||||
pathEl.textContent = "";
|
||||
view.replaceChildren(card(
|
||||
"CLI required",
|
||||
"The vault lives in the primora CLI — the desktop app drives the same vault file, so terminal and desktop always agree.",
|
||||
el("pre", { class: "codeblock" }, "npm i -g @primora/cli\nprimora vault init"),
|
||||
dimNote("Reopen this window after installing.")
|
||||
));
|
||||
}
|
||||
|
||||
function renderInit() {
|
||||
setPill("none");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "new-password" });
|
||||
const pw2 = el("input", { type: "password", placeholder: "Confirm password", autocomplete: "new-password" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (pw.value.length < 8) { err.textContent = "Use at least 8 characters."; err.hidden = false; return; }
|
||||
if (pw.value !== pw2.value) { err.textContent = "Passwords do not match."; err.hidden = false; return; }
|
||||
busy(btn, "Creating…");
|
||||
try { await tauri.core.invoke("vault_init", { password: pw.value }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Create vault");
|
||||
view.replaceChildren(card(
|
||||
"Create your vault",
|
||||
"Local, encrypted, Argon2id + XChaCha20-Poly1305. The password is not recoverable — lose it and the contents are gone.",
|
||||
pw, pw2, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function renderLocked() {
|
||||
setPill("locked");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "current-password", autofocus: true });
|
||||
const ttl = el("select", {},
|
||||
el("option", { value: "900" }, "Unlock for 15 minutes"),
|
||||
el("option", { value: "3600" }, "Unlock for 1 hour"),
|
||||
el("option", { value: "28800" }, "Unlock for 8 hours"),
|
||||
el("option", { value: "86400" }, "Unlock for 24 hours"));
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
busy(btn, "Unlocking…");
|
||||
try { await tauri.core.invoke("vault_unlock", { password: pw.value, ttl: Number(ttl.value) }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Unlock");
|
||||
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") btn.click(); });
|
||||
view.replaceChildren(card(
|
||||
"Vault is locked",
|
||||
"Unlocks a timed session — agents and scripts can use secrets without the password until it expires.",
|
||||
pw, ttl, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function secretRow(s) {
|
||||
const copy = el("button", { class: "ghost sm", title: "Copy value" }, "copy");
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
const value = await tauri.core.invoke("vault_reveal", { name: s.name });
|
||||
await navigator.clipboard.writeText(value.trimEnd());
|
||||
copy.textContent = "copied";
|
||||
setTimeout(() => (copy.textContent = "copy"), 1200);
|
||||
} catch (e) { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); }
|
||||
});
|
||||
const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete");
|
||||
del.addEventListener("click", async () => {
|
||||
if (del.dataset.armed) {
|
||||
try { await tauri.core.invoke("vault_rm", { name: s.name }); } catch {}
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
del.dataset.armed = "1";
|
||||
del.textContent = "sure?";
|
||||
setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500);
|
||||
});
|
||||
return el("tr", {},
|
||||
el("td", { class: "mono" }, s.name),
|
||||
el("td", {}, s.url ? el("span", {
|
||||
class: "vlink",
|
||||
title: `${s.url} — click to copy`,
|
||||
onclick: async () => { await navigator.clipboard.writeText(s.url).catch(() => {}); },
|
||||
}, s.url.replace(/^https?:\/\//, "").slice(0, 34)) : "—"),
|
||||
el("td", { class: "dimcell", title: s.notes ?? "" }, (s.notes ?? "—").slice(0, 34)),
|
||||
el("td", { class: "dimcell" }, (s.updated_at ?? "").slice(0, 10)),
|
||||
el("td", { class: "acts" }, copy, del));
|
||||
}
|
||||
|
||||
async function renderUnlocked(st) {
|
||||
setPill("unlocked", st.expires_at);
|
||||
const secrets = await tauri.core.invoke("vault_secrets").catch(() => []);
|
||||
|
||||
const table = secrets.length
|
||||
? el("table", { class: "vtable" },
|
||||
el("thead", {}, el("tr", {},
|
||||
el("th", {}, "name"), el("th", {}, "url"), el("th", {}, "notes"),
|
||||
el("th", {}, "updated"), el("th", {}, ""))),
|
||||
el("tbody", {}, ...secrets.map(secretRow)))
|
||||
: dimNote("Empty — add a secret below or import a .env file.");
|
||||
|
||||
// inline add form
|
||||
const name = el("input", { placeholder: "NAME", class: "mono", spellcheck: "false" });
|
||||
const value = el("input", { type: "password", placeholder: "value", autocomplete: "off" });
|
||||
const url = el("input", { placeholder: "url (optional)", inputmode: "url" });
|
||||
const notes = el("input", { placeholder: "notes (optional)" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const addBtn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.value)) {
|
||||
err.textContent = "Name must be env-safe: [A-Za-z_][A-Za-z0-9_]*";
|
||||
err.hidden = false; return;
|
||||
}
|
||||
if (!value.value) { err.textContent = "Value required."; err.hidden = false; return; }
|
||||
busy(addBtn, "Encrypting…");
|
||||
try {
|
||||
await tauri.core.invoke("vault_set", {
|
||||
name: name.value, value: value.value, url: url.value || null, notes: notes.value || null,
|
||||
});
|
||||
} catch (e) { err.textContent = String(e); err.hidden = false; unbusy(addBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Add secret");
|
||||
|
||||
const importPath = el("input", { placeholder: "/path/to/.env", class: "mono grow" });
|
||||
const importBtn = el("button", {
|
||||
class: "ghost",
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!importPath.value.trim()) return;
|
||||
busy(importBtn, "Importing…");
|
||||
try { await tauri.core.invoke("vault_import", { path: importPath.value.trim(), overwrite: false }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(importBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Import .env");
|
||||
|
||||
view.replaceChildren(
|
||||
el("section", { class: "vcard" },
|
||||
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
|
||||
table,
|
||||
el("div", { class: "vform" }, name, value, url, notes, addBtn),
|
||||
el("div", { class: "vform" }, importPath, importBtn),
|
||||
err),
|
||||
el("p", { class: "hint" },
|
||||
"Values are never shown here — copy goes straight to the clipboard. " +
|
||||
"Injection and the credential broker stay in the CLI: primora inject / primora agent.")
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
async function refresh() {
|
||||
if (!tauri) {
|
||||
view.replaceChildren(errBox("This page is meant to run inside the Primora desktop app."));
|
||||
return;
|
||||
}
|
||||
const ok = await tauri.core.invoke("vault_probe").catch(() => false);
|
||||
if (!ok) return renderMissing();
|
||||
const st = await tauri.core.invoke("vault_status").catch((e) => ({ error: String(e) }));
|
||||
if (st.error) { view.replaceChildren(errBox(st.error)); return; }
|
||||
pathEl.textContent = st.path ?? "";
|
||||
if (!st.exists) return renderInit();
|
||||
if (!st.unlocked) return renderLocked();
|
||||
renderUnlocked(st);
|
||||
}
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
await tauri.core.invoke("vault_lock").catch(() => {});
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Keep the pill/table honest if the session expires while the window sits open.
|
||||
setInterval(async () => {
|
||||
const st = await tauri?.core.invoke("vault_status").catch(() => null);
|
||||
if (st && !st.unlocked && !lockBtn.hidden) refresh();
|
||||
}, 30000);
|
||||
|
||||
refresh();
|
||||
Reference in new issue
Block a user