AkurAI Build
Menu

Public repository / 11e144cbad3ab1009e59df3196dd0848fb7e016b

AkurAI-PassVault / crates/passvault-mcp/src/server.rs

Latest change 98eaefbfeac4a34b8323a05191f4e6be0fdef059 - AKURAI-PASSVAULT-3 clear folder on explicit null by Ólafur Búi Ólafsson

use crate::tools::ToolRegistry;
use akurai_http::form::{field, parse_urlencoded};
use akurai_http::middleware_builtins::SecurityHeaders;
use akurai_http::{MiddlewareStack, Response, Server};
use akurai_storage::BTree;
use parking_lot::Mutex as PLMutex;
use passvault_core::crypto::Crypto;
use rand::Rng;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

fn hex32() -> String {
    let mut rng = rand::thread_rng();
    (0..32)
        .map(|_| format!("{:02x}", rng.gen::<u8>()))
        .collect()
}

fn parse_cookies(header: &str) -> HashMap<String, String> {
    header
        .split(';')
        .filter_map(|pair| {
            let mut parts = pair.splitn(2, '=');
            let key = parts.next()?.trim().to_string();
            let val = parts.next().unwrap_or("").trim().to_string();
            Some((key, val))
        })
        .collect()
}

const LANDING_HTML: &str = r##"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="Encrypted secrets you control, with CLI and MCP access.">
  <title>AkurAI-PassVault — secrets you control</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="https://akurai-framework.olibuijr.com/themes.css">
  <script>
    (function () {
      try {
        var m = document.cookie.match(/(?:^|; )akurai-theme=([^;]*)/);
        var t = (m ? decodeURIComponent(m[1]) : null) || localStorage.getItem("akurai-theme");
        if (!t) t = matchMedia("(prefers-color-scheme: light)").matches ? "akurai-light" : "akurai";
        document.documentElement.setAttribute("data-theme", t);
      } catch (e) {}
    })();
  </script>
  <script type="module" src="https://akurai-framework.olibuijr.com/theme.js"></script>
  <style>
    :root { --sans: Inter, ui-sans-serif, system-ui, sans-serif; }
    * { box-sizing: border-box; }
    html { scroll-behavior: smooth; }
    body { min-width: 320px; margin: 0; background: var(--bg); color: var(--fg); font: 400 16px/1.6 var(--sans); -webkit-font-smoothing: antialiased; }
    a { color: inherit; text-decoration: none; }
    button { font: inherit; }
    .shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; }
    .nav { display: flex; align-items: center; justify-content: space-between; min-height: 76px; gap: 24px; }
    .brand { display: inline-flex; align-items: center; gap: 10px; color: var(--fg); font-size: 1.1rem; font-weight: 700; letter-spacing: -.04em; }
    .mark { width: 30px; height: 30px; color: var(--accent-2); }
    .mark svg { display: block; width: 100%; height: 100%; }
    .brand-dim, .nav-link { color: var(--muted); font-weight: 500; }
    .nav-links { display: flex; align-items: center; gap: 20px; }
    .nav-link { font-size: .9rem; }
    .nav-link:hover { color: var(--fg); }
    .theme-control { position: relative; }
    .theme-toggle, .theme-option { appearance: none; border: 1px solid var(--border); background: var(--panel); color: var(--fg); cursor: pointer; }
    .theme-toggle { display: inline-flex; align-items: center; gap: 7px; min-height: 35px; padding: 0 10px; border-radius: 9px; font-size: .78rem; font-weight: 600; }
    .theme-toggle:hover, .theme-option:hover { border-color: var(--accent); }
    .theme-toggle::before { width: 9px; height: 9px; border: 2px solid var(--accent-2); border-radius: 50%; content: ""; }
    .theme-menu { position: absolute; z-index: 2; top: calc(100% + 9px); right: 0; display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); gap: 6px; width: 280px; padding: 8px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-2); box-shadow: var(--shadow); }
    .theme-menu[hidden] { display: none; }
    .theme-option { min-height: 34px; border-radius: 7px; font-size: .76rem; text-align: left; }
    .theme-option.is-active { border-color: var(--accent); color: var(--accent-2); }
    .hero { position: relative; overflow: hidden; padding: 92px 0 108px; border-bottom: 1px solid var(--border); isolation: isolate; }
    .hero::before { position: absolute; z-index: -1; top: -280px; left: 50%; width: 780px; height: 560px; transform: translateX(-50%); background: radial-gradient(ellipse, color-mix(in srgb, var(--accent) 34%, transparent), transparent 68%); content: ""; }
    .eyebrow { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: .77rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
    .eyebrow::before { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-2) 16%, transparent); content: ""; }
    h1 { max-width: 800px; margin: 24px 0 20px; font-size: clamp(2.7rem, 7vw, 5.6rem); line-height: .98; letter-spacing: -.075em; }
    .grad { background: linear-gradient(100deg, var(--accent-2), var(--fg) 48%, var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
    .lede { max-width: 650px; margin: 0; color: var(--muted); font-size: clamp(1.05rem, 2vw, 1.25rem); }
    .actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 34px; }
    .button { display: inline-flex; align-items: center; justify-content: center; min-height: 46px; padding: 0 19px; border: 1px solid var(--border); border-radius: 10px; font-weight: 600; transition: transform .2s, border-color .2s, background .2s; }
    .button:hover { transform: translateY(-2px); border-color: var(--accent); }
    .button-primary { border-color: var(--accent); background: var(--accent); color: var(--bg); box-shadow: 0 12px 32px color-mix(in srgb, var(--accent) 30%, transparent); }
    .button-secondary { color: var(--muted); }
    .security-note { display: flex; align-items: center; gap: 9px; margin-top: 27px; color: var(--muted); font-size: .87rem; }
    .lock { color: var(--accent-2); }
    .features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; padding: 76px 0; }
    .feature { min-height: 255px; padding: 27px; border: 1px solid var(--border); border-radius: 16px; background: var(--panel); box-shadow: var(--shadow); }
    .feature-icon { display: grid; width: 43px; height: 43px; place-items: center; border-radius: 12px; background: color-mix(in srgb, var(--accent) 18%, var(--panel)); color: var(--accent-2); font-size: 1.25rem; font-weight: 700; }
    .feature h2 { margin: 25px 0 8px; font-size: 1.15rem; letter-spacing: -.035em; }
    .feature p { margin: 0; color: var(--muted); font-size: .93rem; }
    footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 26px 0 36px; border-top: 1px solid var(--border); color: var(--muted); font-size: .84rem; }
    footer strong { color: var(--fg); }
    @media (max-width: 720px) {
      .shell { width: min(100% - 32px, 1120px); }
      .nav { min-height: 68px; gap: 14px; }
      .nav-links { gap: 12px; }
      .nav-link { display: none; }
      .hero { padding: 60px 0 76px; }
      .features { grid-template-columns: 1fr; padding: 48px 0; }
      .feature { min-height: auto; }
      footer { align-items: flex-start; flex-direction: column; }
    }
  </style>
</head>
<body>
  <header class="shell nav">
    <a class="brand" href="/" aria-label="AkurAI PassVault home">
      <span class="mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.38 3.23c1.05-2.23 4.19-2.23 5.24 0l11.05 23.62A2.91 2.91 0 0 1 27.03 31h-3.42c-1.17 0-2.22-.7-2.67-1.78l-2.05-4.93h-5.78l-2.05 4.93A2.9 2.9 0 0 1 8.39 31H4.97a2.91 2.91 0 0 1-2.64-4.15L13.38 3.23ZM16 11.17l-5.13 13.1h10.26L16 11.17Z"/><path d="M10.38 20.54h11.24a2.23 2.23 0 0 1 0 4.46H10.38a2.23 2.23 0 0 1 0-4.46Z"/></svg></span>
      <span>AkurAI<span class="brand-dim">/PassVault</span></span>
    </a>
    <nav class="nav-links" aria-label="Primary navigation">
      <a class="nav-link" href="#features">Features</a>
      <a class="nav-link" href="https://github.com/olibuijr/AkurAI-PassVault">Source</a>
      <div class="theme-control">
        <button class="theme-toggle" type="button" aria-expanded="false" aria-controls="theme-menu">Theme <span data-theme-label></span></button>
        <div class="theme-menu" id="theme-menu" hidden>
          <button class="theme-option" type="button" data-theme="akurai">AkurAI</button>
          <button class="theme-option" type="button" data-theme="akurai-light">AkurAI Light</button>
          <button class="theme-option" type="button" data-theme="claude-code">Claude Code</button>
          <button class="theme-option" type="button" data-theme="claude-code-light">Claude Light</button>
          <button class="theme-option" type="button" data-theme="nord">Nord</button>
          <button class="theme-option" type="button" data-theme="nord-light">Nord Light</button>
          <button class="theme-option" type="button" data-theme="catppuccin-mocha">Catppuccin Mocha</button>
          <button class="theme-option" type="button" data-theme="catppuccin-latte">Catppuccin Latte</button>
          <button class="theme-option" type="button" data-theme="solarized-dark">Solarized Dark</button>
          <button class="theme-option" type="button" data-theme="solarized-light">Solarized Light</button>
          <button class="theme-option" type="button" data-theme="gruvbox-dark">Gruvbox Dark</button>
          <button class="theme-option" type="button" data-theme="gruvbox-light">Gruvbox Light</button>
          <button class="theme-option" type="button" data-theme="tokyo-night">Tokyo Night</button>
          <button class="theme-option" type="button" data-theme="tokyo-night-light">Tokyo Light</button>
          <button class="theme-option" type="button" data-theme="rose-pine">Rosé Pine</button>
          <button class="theme-option" type="button" data-theme="rose-pine-dawn">Rosé Pine Dawn</button>
          <button class="theme-option" type="button" data-theme="dracula">Dracula</button>
        </div>
      </div>
    </nav>
  </header>
  <main>
    <section class="hero">
      <div class="shell">
        <span class="eyebrow">Private secret infrastructure</span>
        <h1>AkurAI-PassVault — <span class="grad">secrets you control.</span></h1>
        <p class="lede">Keep credentials encrypted in a vault you own. Retrieve only what a person, CLI, or MCP client needs—when it needs it.</p>
        <div class="actions">
          <a class="button button-primary" href="https://auth.olibuijr.com/login">Sign in with AkurAI <span aria-hidden="true">&nbsp;→</span></a>
          <a class="button button-secondary" href="https://github.com/olibuijr/AkurAI-PassVault">Get started</a>
        </div>
        <p class="security-note"><span class="lock" aria-hidden="true">⌁</span> Encrypted at rest. Your vault, your keys.</p>
      </div>
    </section>
    <section class="shell features" id="features" aria-label="PassVault capabilities">
      <article class="feature">
        <div class="feature-icon" aria-hidden="true">⌁</div>
        <h2>Encrypted storage</h2>
        <p>Store sensitive values in a compact, encrypted vault designed to keep secret material under your control.</p>
      </article>
      <article class="feature">
        <div class="feature-icon" aria-hidden="true">⌘</div>
        <h2>CLI &amp; MCP access</h2>
        <p>Use the command line when you are working locally, or connect an MCP client over HTTPS when your tools need context.</p>
      </article>
      <article class="feature">
        <div class="feature-icon" aria-hidden="true">↗</div>
        <h2>Programmatic retrieval</h2>
        <p>Give trusted automation the exact secret it needs without copying credentials into config files, logs, or chat.</p>
      </article>
    </section>
  </main>
  <footer class="shell">
    <strong>AkurAI<span class="brand-dim">/PassVault</span></strong>
    <span>Secrets you own. Access you control.</span>
    <span>© 2026 AkurAI</span>
  </footer>
  <script>
    (function () {
      var toggle = document.querySelector(".theme-toggle");
      var menu = document.getElementById("theme-menu");
      var label = document.querySelector("[data-theme-label]");
      var options = document.querySelectorAll(".theme-option");
      function current() { return document.documentElement.getAttribute("data-theme") || "akurai"; }
      function sync() {
        var active = current();
        options.forEach(function (option) {
          var selected = option.getAttribute("data-theme") === active;
          option.classList.toggle("is-active", selected);
          option.setAttribute("aria-pressed", selected);
          if (selected) label.textContent = option.textContent;
        });
      }
      function close() { menu.hidden = true; toggle.setAttribute("aria-expanded", "false"); }
      toggle.addEventListener("click", function () {
        var open = menu.hidden;
        menu.hidden = !open;
        toggle.setAttribute("aria-expanded", open);
      });
      options.forEach(function (option) {
        option.addEventListener("click", function () {
          var theme = option.getAttribute("data-theme");
          document.documentElement.setAttribute("data-theme", theme);
          try {
            localStorage.setItem("akurai-theme", theme);
            document.cookie = "akurai-theme=" + encodeURIComponent(theme) + "; path=/; max-age=31536000; samesite=lax; domain=.olibuijr.com; secure";
          } catch (e) {}
          sync();
          close();
        });
      });
      document.addEventListener("click", function (event) {
        if (!event.target.closest(".theme-control")) close();
      });
      document.addEventListener("keydown", function (event) { if (event.key === "Escape") close(); });
      sync();
    })();
  </script>
</body>
</html>"##;

const LOGIN_HTML: &str = r##"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Sign in — AkurAI-PassVault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="https://akurai-framework.olibuijr.com/themes.css">
  <script>
    (function () {
      try {
        var m = document.cookie.match(/(?:^|; )akurai-theme=([^;]*)/);
        var t = (m ? decodeURIComponent(m[1]) : null) || localStorage.getItem("akurai-theme");
        if (!t) t = matchMedia("(prefers-color-scheme: light)").matches ? "akurai-light" : "akurai";
        document.documentElement.setAttribute("data-theme", t);
      } catch (e) {}
    })();
  </script>
  <script type="module" src="https://akurai-framework.olibuijr.com/theme.js"></script>
  <style>
    :root { --sans: Inter, ui-sans-serif, system-ui, sans-serif; }
    * { box-sizing: border-box; }
    body { min-width: 320px; margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--bg); color: var(--fg); font: 400 16px/1.6 var(--sans); -webkit-font-smoothing: antialiased; }
    .card { width: min(400px, calc(100% - 32px)); padding: 40px 32px; border: 1px solid var(--border); border-radius: 16px; background: var(--panel); box-shadow: var(--shadow); }
    .mark { width: 36px; height: 36px; color: var(--accent-2); display: block; margin-bottom: 18px; }
    h1 { margin: 0 0 6px; font-size: 1.5rem; letter-spacing: -.03em; }
    p { margin: 0 0 24px; color: var(--muted); font-size: .9rem; }
    label { display: block; margin-bottom: 18px; font-size: .85rem; font-weight: 500; color: var(--muted); }
    input { display: block; width: 100%; margin-top: 6px; padding: 11px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg); color: var(--fg); font: inherit; font-size: .95rem; }
    input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
    .btn { display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 44px; padding: 0 18px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent); color: var(--bg); font-weight: 600; cursor: pointer; font: inherit; font-size: .95rem; }
    .btn:hover { filter: brightness(1.08); }
    .error { display: none; margin-bottom: 18px; padding: 11px 14px; border: 1px solid var(--danger); border-radius: 10px; background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); font-size: .85rem; }
    .error.visible { display: block; }
  </style>
</head>
<body>
  <div class="card">
    <svg class="mark" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.38 3.23c1.05-2.23 4.19-2.23 5.24 0l11.05 23.62A2.91 2.91 0 0 1 27.03 31h-3.42c-1.17 0-2.22-.7-2.67-1.78l-2.05-4.93h-5.78l-2.05 4.93A2.9 2.9 0 0 1 8.39 31H4.97a2.91 2.91 0 0 1-2.64-4.15L13.38 3.23ZM16 11.17l-5.13 13.1h10.26L16 11.17Z"/><path d="M10.38 20.54h11.24a2.23 2.23 0 0 1 0 4.46H10.38a2.23 2.23 0 0 1 0-4.46Z"/></svg>
    <h1>Sign in</h1>
    <p>Enter your vault token to access the dashboard.</p>
    <div class="error" id="error-msg"></div>
    <form method="post" action="/login">
      <label>Vault token<input type="password" name="token" placeholder="Bearer token" autocomplete="off" required /></label>
      <button class="btn" type="submit">Sign in</button>
    </form>
    <p style="margin-top:18px;text-align:center;font-size:.8rem"><a href="/" style="color:var(--accent)">← Back to landing</a></p>
  </div>
</body>
</html>"##;

const DASHBOARD_HTML_PREFIX: &str = r##"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>AkurAI-PassVault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="https://akurai-framework.olibuijr.com/themes.css">
  <script>
    (function () {
      try {
        var m = document.cookie.match(/(?:^|; )akurai-theme=([^;]*)/);
        var t = (m ? decodeURIComponent(m[1]) : null) || localStorage.getItem("akurai-theme");
        if (!t) t = matchMedia("(prefers-color-scheme: light)").matches ? "akurai-light" : "akurai";
        document.documentElement.setAttribute("data-theme", t);
      } catch (e) {}
    })();
  </script>
  <script type="module" src="https://akurai-framework.olibuijr.com/theme.js"></script>
  <style>
    :root { --sans: Inter, ui-sans-serif, system-ui, sans-serif; }
    * { box-sizing: border-box; }
    html { scroll-behavior: smooth; }
    body { min-width: 320px; margin: 0; background: var(--bg); color: var(--fg); font: 400 15px/1.5 var(--sans); -webkit-font-smoothing: antialiased; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }
    a { color: inherit; text-decoration: none; }
    button, input, select, textarea { font: inherit; }
    .topbar { display: flex; align-items: center; gap: 16px; height: 56px; padding: 0 20px; border-bottom: 1px solid var(--border); flex: none; }
    .brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem; }
    .brand .mark { width: 24px; height: 24px; color: var(--accent-2); flex: none; }
    .brand-dim { color: var(--muted); font-weight: 500; }
    .topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; }
    .search-box { position: relative; width: 240px; }
    .search-box input { width: 100%; padding: 7px 12px 7px 32px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-2); color: var(--fg); font-size: .85rem; }
    .search-box input:focus { outline: none; border-color: var(--accent); }
    .search-box::before { content: ""; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; border: 2px solid var(--muted); border-radius: 50%; }
    .search-box::after { content: ""; position: absolute; left: 21px; top: calc(50% + 6px); width: 2px; height: 7px; background: var(--muted); transform: rotate(-45deg); }
    .btn { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--fg); font-size: .85rem; font-weight: 500; cursor: pointer; white-space: nowrap; }
    .btn:hover { border-color: var(--accent); }
    .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
    .btn-danger { border-color: var(--danger); color: var(--danger); }
    .btn-sm { min-height: 28px; padding: 0 10px; font-size: .8rem; }
    .shell { display: flex; flex: 1; overflow: hidden; }
    .sidebar { width: 220px; flex: none; border-right: 1px solid var(--border); padding: 12px; overflow-y: auto; }
    .sidebar-heading { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
    .sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: .88rem; cursor: pointer; color: var(--muted); }
    .sidebar-item:hover { background: var(--panel); color: var(--fg); }
    .sidebar-item.active { background: var(--panel); color: var(--fg); font-weight: 500; }
    .sidebar-item .count { margin-left: auto; background: var(--border); border-radius: 999px; padding: 1px 7px; font-size: .75rem; color: var(--muted); }
    .content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
    .entry-list { flex: 1; overflow-y: auto; padding: 12px 16px; }
    .entry-row { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 12px; align-items: center; padding: 10px 14px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; }
    .entry-row:hover { background: var(--panel); border-color: var(--border); }
    .entry-row.selected { background: var(--panel); border-color: var(--accent); }
    .entry-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .entry-meta { color: var(--muted); font-size: .83rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .folder-badge { display: inline-flex; padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-2); font-size: .75rem; }
    .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; color: var(--muted); text-align: center; padding: 40px; }
    .empty-state h2 { margin: 12px 0 6px; font-size: 1.3rem; color: var(--fg); }
    .empty-state p { max-width: 360px; font-size: .9rem; }
    .detail-panel { width: 0; overflow: hidden; border-left: 1px solid var(--border); transition: width .2s; }
    .detail-panel.open { width: 380px; overflow-y: auto; }
    .detail-inner { width: 380px; padding: 20px; }
    .detail-inner h2 { margin: 0 0 18px; font-size: 1.1rem; }
    .detail-inner label { display: block; margin-bottom: 14px; font-size: .8rem; font-weight: 500; color: var(--muted); }
    .detail-inner input, .detail-inner textarea, .detail-inner select { display: block; width: 100%; margin-top: 5px; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); font-size: .88rem; }
    .detail-inner input:focus, .detail-inner textarea:focus { outline: none; border-color: var(--accent); }
    .detail-inner textarea { resize: vertical; min-height: 60px; }
    .detail-inner select { cursor: pointer; }
    .detail-actions { display: flex; gap: 8px; margin-top: 18px; flex-wrap: wrap; }
    .toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 18px; border-radius: 10px; background: var(--panel); border: 1px solid var(--border); box-shadow: var(--shadow); font-size: .85rem; z-index: 100; opacity: 0; transform: translateY(10px); transition: opacity .25s, transform .25s; pointer-events: none; }
    .toast.visible { opacity: 1; transform: translateY(0); }
    .toast.ok { border-color: var(--ok); }
    .toast.err { border-color: var(--danger); }
    .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 200; }
    .modal-overlay[hidden] { display: none; }
    .modal { width: min(420px, calc(100% - 32px)); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--panel); box-shadow: var(--shadow); }
    .modal h2 { margin: 0 0 8px; font-size: 1.15rem; }
    .modal p { color: var(--muted); font-size: .9rem; margin: 0 0 20px; }
    .pw-gen { display: flex; gap: 8px; align-items: center; margin-top: 5px; }
    .pw-gen input { flex: 1; }
    .pw-gen .btn { flex: none; }
    .health-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--ok); }
  </style>
</head>
<body>
<div class="topbar">
  <a class="brand" href="/app" aria-label="AkurAI PassVault home">
    <svg class="mark" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.38 3.23c1.05-2.23 4.19-2.23 5.24 0l11.05 23.62A2.91 2.91 0 0 1 27.03 31h-3.42c-1.17 0-2.22-.7-2.67-1.78l-2.05-4.93h-5.78l-2.05 4.93A2.9 2.9 0 0 1 8.39 31H4.97a2.91 2.91 0 0 1-2.64-4.15L13.38 3.23ZM16 11.17l-5.13 13.1h10.26L16 11.17Z"/><path d="M10.38 20.54h11.24a2.23 2.23 0 0 1 0 4.46H10.38a2.23 2.23 0 0 1 0-4.46Z"/></svg>
    AkurAI<span class="brand-dim">/PassVault</span>
  </a>
  <div class="search-box"><input type="text" id="search-input" placeholder="Search entries…" aria-label="Search vault entries"></div>
  <div class="topbar-actions">
    <span class="health-dot" id="health-dot" title="Connected"></span>
    <button class="btn btn-primary btn-sm" id="new-entry-btn" type="button">+ New entry</button>
    <button class="btn btn-sm" id="generate-pw-btn" type="button">Generate</button>
    <span data-theme-picker></span>
    <a class="btn btn-sm" href="/logout">Sign out</a>
  </div>
</div>
<div class="shell">
  <aside class="sidebar" id="sidebar">
    <div class="sidebar-heading"><span>Folders</span><button class="btn btn-sm" id="new-folder-btn" type="button" style="border:0;padding:2px 6px;font-size:.8rem">+ Add</button></div>
    <div class="sidebar-item active" data-folder-id="__all__"><span>📁</span> All entries<span class="count" id="all-count">0</span></div>
    <div id="folder-list"></div>
  </aside>
  <main class="content" id="main-content">
    <div class="entry-list" id="entry-list"></div>
  </main>
  <aside class="detail-panel" id="detail-panel">
    <div class="detail-inner" id="detail-inner"></div>
  </aside>
</div>
<div class="toast" id="toast"></div>
<div class="modal-overlay" id="modal-overlay" hidden>
  <div class="modal" id="modal-content"></div>
</div>
<script>
const TOKEN = '@@TOKEN@@';
const API = '/mcp';

async function mcp(method, args = {}) {
  const res = await fetch(API, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${TOKEN}` },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: method, arguments: args } })
  });
  const data = await res.json();
  if (data.error) throw new Error(data.error.message);
  return JSON.parse(data.result.content[0].text);
}

const state = { entries: [], folders: [], folderId: null, selectedId: null, renderedEntries: [], searchTimer: null };
const entryList = document.getElementById('entry-list');
const folderList = document.getElementById('folder-list');
const detailPanel = document.getElementById('detail-panel');
const detailInner = document.getElementById('detail-inner');
const healthDot = document.getElementById('health-dot');
const modalOverlay = document.getElementById('modal-overlay');
const modalContent = document.getElementById('modal-content');
const toast = document.getElementById('toast');
let toastTimer;

function escapeHtml(value = '') {
  return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
}

function folderOf(entry) {
  return entry.folderId ?? entry.folder_id ?? null;
}

function setHealth(connected) {
  healthDot.style.background = connected ? 'var(--ok)' : 'var(--danger)';
  healthDot.title = connected ? 'Connected' : 'Connection error';
}

function errorMessage(error) {
  return error instanceof Error ? error.message : 'Something went wrong';
}

function showToast(msg, type = 'ok') {
  clearTimeout(toastTimer);
  toast.textContent = msg;
  toast.className = `toast ${type === 'err' ? 'err' : 'ok'}`;
  requestAnimationFrame(() => toast.classList.add('visible'));
  toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000);
}

function reportError(error) {
  setHealth(false);
  showToast(errorMessage(error), 'err');
}

function hideModal() {
  modalOverlay.hidden = true;
  modalContent.innerHTML = '';
}

function showModal(html, confirmCb, confirmLabel = 'Confirm') {
  modalContent.innerHTML = `${html}<div class="detail-actions"><button class="btn" id="modal-cancel" type="button">Cancel</button><button class="btn btn-primary" id="modal-confirm" type="button">${escapeHtml(confirmLabel)}</button></div>`;
  modalOverlay.hidden = false;
  document.getElementById('modal-cancel').onclick = hideModal;
  document.getElementById('modal-confirm').onclick = async event => {
    const button = event.currentTarget;
    button.disabled = true;
    try {
      await confirmCb();
      hideModal();
    } catch (error) {
      reportError(error);
      button.disabled = false;
    }
  };
}

function closeDetail() {
  state.selectedId = null;
  detailPanel.classList.remove('open');
  detailInner.innerHTML = '';
  renderEntries(state.renderedEntries);
}

function showEmptyState() {
  state.renderedEntries = [];
  entryList.innerHTML = '<div class="empty-state"><div aria-hidden="true">🔐</div><h2>No entries yet</h2><p>Add a vault entry to keep credentials securely organized.</p><button class="btn btn-primary" id="empty-new-entry" type="button">+ New entry</button></div>';
  document.getElementById('empty-new-entry').onclick = showNewEntryForm;
}

function renderEntries(entries) {
  state.renderedEntries = entries;
  if (!entries.length) {
    showEmptyState();
    return;
  }
  entryList.innerHTML = entries.map(entry => {
    const folderId = folderOf(entry);
    const folder = state.folders.find(item => item.id === folderId);
    const badge = folderId == null ? '' : `<span class="folder-badge">${escapeHtml(folder ? folder.name : `Folder ${folderId}`)}</span>`;
    return `<div class="entry-row${state.selectedId === entry.id ? ' selected' : ''}" data-entry-id="${entry.id}" role="button" tabindex="0"><span class="entry-name">${escapeHtml(entry.name)}</span><span class="entry-meta">${escapeHtml(entry.username)}</span><span class="entry-meta">${escapeHtml(entry.uri)}</span>${badge}</div>`;
  }).join('');
  entryList.querySelectorAll('.entry-row').forEach(row => {
    const open = () => showDetail(Number(row.dataset.entryId));
    row.onclick = open;
    row.onkeydown = event => {
      if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault();
        open();
      }
    };
  });
}

function renderFolders() {
  document.getElementById('all-count').textContent = String(state.entries.length);
  document.querySelector('[data-folder-id="__all__"]').classList.toggle('active', state.folderId === null);
  folderList.innerHTML = state.folders.map(folder => {
    const count = state.entries.filter(entry => folderOf(entry) === folder.id).length;
    return `<div class="sidebar-item${state.folderId === folder.id ? ' active' : ''}" data-folder-id="${folder.id}" role="button" tabindex="0"><span>📁</span>${escapeHtml(folder.name)}<span class="count">${count}</span></div>`;
  }).join('');
  folderList.querySelectorAll('.sidebar-item').forEach(item => {
    const select = () => {
      state.selectedId = null;
      closeDetail();
      loadEntries(Number(item.dataset.folderId)).catch(reportError);
    };
    item.onclick = select;
    item.onkeydown = event => {
      if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault();
        select();
      }
    };
  });
}

async function loadEntries(folderId) {
  if (folderId !== undefined) state.folderId = folderId;
  state.entries = await mcp('list_entries');
  const entries = state.folderId === null
    ? state.entries
    : state.entries.filter(entry => folderOf(entry) === state.folderId);
  renderEntries(entries);
  renderFolders();
  setHealth(true);
}

async function loadFolders() {
  state.folders = await mcp('list_folders');
  renderFolders();
  renderEntries(state.renderedEntries);
  setHealth(true);
}

function folderOptions(selectedId) {
  return `<option value="">No folder</option>${state.folders.map(folder => `<option value="${folder.id}"${folder.id === selectedId ? ' selected' : ''}>${escapeHtml(folder.name)}</option>`).join('')}`;
}

function entryValues(form) {
  const data = new FormData(form);
  const folderId = data.get('folderId');
  return {
    name: String(data.get('name') || '').trim(),
    username: String(data.get('username') || ''),
    password: String(data.get('password') || ''),
    uri: String(data.get('uri') || ''),
    notes: String(data.get('notes') || ''),
    folderId: folderId ? Number(folderId) : null
  };
}

function renderEntryForm(entry) {
  const isNew = !entry;
  const value = field => escapeHtml(entry ? entry[field] : '');
  detailInner.innerHTML = `<h2>${isNew ? 'New entry' : 'Entry details'}</h2><form id="entry-form" novalidate><label>Name<input name="name" value="${value('name')}" autocomplete="off"></label><label>Username<input name="username" value="${value('username')}" autocomplete="username"></label><label>Password<div class="pw-gen"><input id="entry-password" name="password" type="password" value="${value('password')}" autocomplete="new-password"><button class="btn btn-sm" id="toggle-password" type="button">Show</button></div></label><label>URI<input name="uri" type="url" value="${value('uri')}" autocomplete="url"></label><label>Notes<textarea name="notes">${value('notes')}</textarea></label><label>Folder<select name="folderId">${folderOptions(entry ? folderOf(entry) : null)}</select></label><div class="detail-actions"><button class="btn btn-primary" type="submit">Save</button>${isNew ? '' : '<button class="btn btn-danger" id="delete-entry" type="button">Delete</button>'}</div></form>`;
  detailPanel.classList.add('open');
  const form = document.getElementById('entry-form');
  const password = document.getElementById('entry-password');
  document.getElementById('toggle-password').onclick = event => {
    const showing = password.type === 'text';
    password.type = showing ? 'password' : 'text';
    event.currentTarget.textContent = showing ? 'Show' : 'Hide';
  };
  form.onsubmit = async event => {
    event.preventDefault();
    const values = entryValues(form);
    if (!values.name) {
      showToast('A name is required', 'err');
      return;
    }
    try {
      await mcp(isNew ? 'create_entry' : 'update_entry', isNew ? values : { id: entry.id, ...values });
      showToast(isNew ? 'Entry created' : 'Entry saved');
      closeDetail();
      await refresh();
      setHealth(true);
    } catch (error) {
      reportError(error);
    }
  };
  if (!isNew) {
    document.getElementById('delete-entry').onclick = () => showModal('<h2>Delete entry?</h2><p>This permanently removes the entry from your vault.</p>', async () => {
      await mcp('delete_entry', { id: entry.id });
      showToast('Entry deleted');
      closeDetail();
      await refresh();
      setHealth(true);
    }, 'Delete');
  }
}

async function showDetail(entryId) {
  try {
    const entry = await mcp('get_entry', { id: entryId });
    state.selectedId = entryId;
    renderEntries(state.renderedEntries);
    renderEntryForm(entry);
    setHealth(true);
  } catch (error) {
    reportError(error);
  }
}

function showNewEntryForm() {
  state.selectedId = null;
  renderEntries(state.renderedEntries);
  renderEntryForm(null);
}

async function refresh() {
  await Promise.all([loadEntries(), loadFolders()]);
}

document.querySelector('[data-folder-id="__all__"]').onclick = () => {
  state.selectedId = null;
  closeDetail();
  loadEntries(null).catch(reportError);
};
document.getElementById('new-entry-btn').onclick = showNewEntryForm;
document.getElementById('generate-pw-btn').onclick = async () => {
  try {
    const { password } = await mcp('generate_password', {});
    showModal(`<h2>Generated password</h2><p>Copy it now, then save it in a vault entry.</p><div class="pw-gen"><input id="generated-password" type="text" value="${escapeHtml(password)}" readonly></div>`, async () => {
      await navigator.clipboard.writeText(password);
      showToast('Password copied');
    }, 'Copy');
    setHealth(true);
  } catch (error) {
    reportError(error);
  }
};
document.getElementById('new-folder-btn').onclick = () => showModal('<h2>New folder</h2><p>Choose a name to organize your entries.</p><input id="new-folder-name" aria-label="Folder name" style="width:100%;padding:8px 11px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--fg)">', async () => {
  const name = document.getElementById('new-folder-name').value.trim();
  if (!name) throw new Error('A folder name is required');
  await mcp('create_folder', { name });
  await refresh();
  showToast('Folder created');
  setHealth(true);
}, 'Create');
document.getElementById('search-input').oninput = event => {
  clearTimeout(state.searchTimer);
  const query = event.currentTarget.value.trim();
  state.searchTimer = setTimeout(async () => {
    try {
      if (!query) {
        await loadEntries();
        return;
      }
      const entries = await mcp('search_entries', { query });
      if (document.getElementById('search-input').value.trim() === query) {
        state.selectedId = null;
        closeDetail();
        renderEntries(entries);
        setHealth(true);
      }
    } catch (error) {
      reportError(error);
    }
  }, 300);
};
modalOverlay.onclick = event => {
  if (event.target === modalOverlay) hideModal();
};
document.addEventListener('keydown', event => {
  if (event.key === 'Escape' && !modalOverlay.hidden) hideModal();
});

(async () => {
  try {
    await refresh();
  } catch (error) {
    reportError(error);
  }
})();
</script>
</body>
</html>"##;

pub struct McpConfig {
    pub host: String,
    pub port: u16,
    pub db_path: String,
    pub token: String,
    pub encryption_key: String,
}

pub struct McpServer {
    config: McpConfig,
}

impl McpServer {
    pub fn new(config: McpConfig) -> Self {
        McpServer { config }
    }

    pub fn serve(&self) -> Result<(), String> {
        let db =
            BTree::open(&self.config.db_path).map_err(|e| format!("failed to open db: {e}"))?;
        let db = Arc::new(Mutex::new(db));
        let crypto = Crypto::from_hex_key(&self.config.encryption_key)
            .map_err(|e| format!("crypto init: {e}"))?;
        let crypto = Arc::new(crypto);
        let token = self.config.token.clone();
        let registry = Arc::new(ToolRegistry::new(db, crypto));
        let sessions: Arc<PLMutex<HashMap<String, String>>> =
            Arc::new(PLMutex::new(HashMap::new()));

        let handler = move |req: &akurai_http::Request| -> Response {
            if req.path == "/" && req.method == akurai_http::Method::Get {
                return Response::new(200).with_html(LANDING_HTML);
            }
            if req.path == "/login" && req.method == akurai_http::Method::Get {
                return Response::new(200).with_html(LOGIN_HTML);
            }
            if req.path == "/login" && req.method == akurai_http::Method::Post {
                let body = req.body_str();
                let pairs = parse_urlencoded(&body);
                let submitted = field(&pairs, "token").unwrap_or("");
                return if submitted == token {
                    let sid = hex32();
                    sessions.lock().insert(sid.clone(), token.clone());
                    Response::new(303)
                        .with_header("Location", "/app")
                        .with_header(
                            "Set-Cookie",
                            &format!(
                                "passvault_session={sid}; path=/; HttpOnly; SameSite=Lax; Secure"
                            ),
                        )
                        .with_text("ok")
                } else {
                    Response::new(200).with_html(&LOGIN_HTML.replace(
                        "id=\"error-msg\"></div>",
                        "id=\"error-msg\" class=\"visible\">Invalid token</div>",
                    ))
                };
            }
            if req.path == "/logout" && req.method == akurai_http::Method::Get {
                let cookie = req.header("cookie").unwrap_or("");
                let cookies = parse_cookies(cookie);
                if let Some(sid) = cookies.get("passvault_session") {
                    sessions.lock().remove(sid);
                }
                return Response::new(303)
                    .with_header("Location", "/login")
                    .with_header(
                        "Set-Cookie",
                        "passvault_session=; path=/; HttpOnly; Max-Age=0",
                    )
                    .with_text("ok");
            }
            if req.path == "/app" && req.method == akurai_http::Method::Get {
                let cookie = req.header("cookie").unwrap_or("");
                let cookies = parse_cookies(cookie);
                let sid = match cookies.get("passvault_session") {
                    Some(s) => s.clone(),
                    None => {
                        return Response::new(303)
                            .with_header("Location", "/login")
                            .with_text("redirect")
                    }
                };
                let sessions_guard = sessions.lock();
                let sess_token = match sessions_guard.get(&sid) {
                    Some(t) => t.clone(),
                    None => {
                        return Response::new(303)
                            .with_header("Location", "/login")
                            .with_text("redirect")
                    }
                };
                drop(sessions_guard);
                let html = DASHBOARD_HTML_PREFIX.replace("@@TOKEN@@", &sess_token);
                return Response::new(200).with_html(&html);
            }
            if req.path == "/api/health" && req.method == akurai_http::Method::Get {
                let body = format!(
                    r#"{{"app":"akurai-passvault","version":"{}","status":"ok","framework":"AkurAI-Framework","framework_version":"0.8.2"}}"#,
                    env!("CARGO_PKG_VERSION")
                );
                return Response::new(200)
                    .with_header("Content-Type", "application/json")
                    .with_body("application/json", body.into_bytes());
            }
            if req.path != "/mcp" || req.method != akurai_http::Method::Post {
                return Response::new(404).with_text("not found");
            }

            // MCP auth: Bearer token or session cookie
            let expected = format!("Bearer {token}");
            let auth_hdr = req.header("authorization").unwrap_or("");
            let auth_ok = auth_hdr == expected;
            let session_ok = if !auth_ok {
                let cookie = req.header("cookie").unwrap_or("");
                let cookies = parse_cookies(cookie);
                cookies
                    .get("passvault_session")
                    .and_then(|sid| sessions.lock().get(sid).cloned())
                    .map(|st| st == token)
                    .unwrap_or(false)
            } else {
                false
            };
            if !auth_ok && !session_ok {
                return Response::new(401).with_text("unauthorized");
            }

            let body = req.body_str();
            let result = registry.handle_request(&body);
            // Empty result = JSON-RPC notification: acknowledge with 202 + empty body
            // (MCP Streamable-HTTP spec). Strict clients (codex/rmcp) break if a
            // notification receives a JSON-RPC response body.
            if result.is_empty() {
                return Response::new(202);
            }
            Response::new(200)
                .with_header("Content-Type", "application/json")
                .with_body("application/json", result.into_bytes())
        };

        let addr = format!("{}:{}", self.config.host, self.config.port);
        eprintln!("AkurAI-PassVault listening on {addr}");
        Server::bind(&addr)
            .map_err(|e| format!("bind: {e}"))?
            .run_with(MiddlewareStack::new().push(SecurityHeaders), handler)
            .map_err(|e| format!("server: {e}"))
    }
}