mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
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>
248 lines
9.4 KiB
JavaScript
248 lines
9.4 KiB
JavaScript
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();
|