From 0374899dab12f6d519e28ce2c49a9aa4512fcec9 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 13 Apr 2026 08:49:57 +0800 Subject: [PATCH] feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack - Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db - Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp* - Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md - Apply rustfmt; fix clippy (collapsible_if, HTTP method as str) --- .gitea/workflows/secrets.yml | 181 +- .gitignore | 11 +- .vscode/tasks.json | 46 - AGENTS.md | 423 +- CONTRIBUTING.md | 13 +- Cargo.lock | 3581 ++++++++-- Cargo.toml | 23 +- README.md | 377 +- apps/api/Cargo.toml | 27 + apps/api/src/bin/secrets-api-migrate.rs | 15 + apps/api/src/main.rs | 568 ++ apps/desktop/README.md | 6 + apps/desktop/design/DESIGN.md | 208 + apps/desktop/design/secrets-client.pen | 6300 +++++++++++++++++ apps/desktop/src-tauri/Cargo.toml | 32 + apps/desktop/src-tauri/build.rs | 3 + apps/desktop/src-tauri/check_png_center.js | 2 + .../src-tauri/gen/schemas/acl-manifests.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 1 + .../src-tauri/gen/schemas/desktop-schema.json | 2244 ++++++ .../src-tauri/gen/schemas/macOS-schema.json | 2244 ++++++ apps/desktop/src-tauri/icons/icon.png | Bin 0 -> 6409 bytes apps/desktop/src-tauri/src/local_vault.rs | 1427 ++++ apps/desktop/src-tauri/src/main.rs | 1179 +++ apps/desktop/src-tauri/src/session_api.rs | 356 + apps/desktop/src-tauri/tauri.conf.json | 31 + crates/application/Cargo.toml | 18 + crates/application/src/conflict.rs | 9 + crates/application/src/lib.rs | 3 + crates/application/src/sync.rs | 252 + crates/application/src/vault_store.rs | 147 + crates/client-integrations/Cargo.toml | 13 + crates/client-integrations/src/lib.rs | 162 + crates/crypto/Cargo.toml | 14 + crates/crypto/src/lib.rs | 47 + crates/desktop-daemon/Cargo.toml | 26 + crates/desktop-daemon/src/config.rs | 23 + .../src/exec.rs | 61 - crates/desktop-daemon/src/lib.rs | 684 ++ crates/desktop-daemon/src/main.rs | 26 + .../src/target.rs | 135 +- crates/desktop-daemon/src/vault_client.rs | 168 + crates/device-auth/Cargo.toml | 16 + crates/device-auth/src/lib.rs | 27 + crates/domain/Cargo.toml | 16 + crates/domain/src/auth.rs | 68 + crates/domain/src/cipher.rs | 138 + crates/domain/src/error.rs | 15 + crates/domain/src/kdf.rs | 37 + crates/domain/src/lib.rs | 19 + crates/domain/src/sync.rs | 47 + crates/domain/src/vault_object.rs | 48 + crates/infrastructure-db/Cargo.toml | 15 + crates/infrastructure-db/src/lib.rs | 29 + crates/infrastructure-db/src/migrate.rs | 108 + crates/secrets-core/Cargo.toml | 27 - crates/secrets-core/src/audit.rs | 88 - crates/secrets-core/src/config.rs | 71 - crates/secrets-core/src/crypto.rs | 128 - crates/secrets-core/src/db.rs | 657 -- crates/secrets-core/src/error.rs | 172 - crates/secrets-core/src/lib.rs | 8 - crates/secrets-core/src/models.rs | 357 - crates/secrets-core/src/service/add.rs | 813 --- crates/secrets-core/src/service/api_key.rs | 95 - crates/secrets-core/src/service/audit_log.rs | 39 - crates/secrets-core/src/service/delete.rs | 823 --- crates/secrets-core/src/service/env_map.rs | 122 - crates/secrets-core/src/service/export.rs | 144 - crates/secrets-core/src/service/get_secret.rs | 105 - crates/secrets-core/src/service/history.rs | 64 - crates/secrets-core/src/service/import.rs | 180 - crates/secrets-core/src/service/mod.rs | 15 - crates/secrets-core/src/service/relations.rs | 324 - crates/secrets-core/src/service/rollback.rs | 343 - crates/secrets-core/src/service/search.rs | 421 -- crates/secrets-core/src/service/update.rs | 562 -- crates/secrets-core/src/service/user.rs | 349 - crates/secrets-core/src/service/util.rs | 27 - crates/secrets-core/src/taxonomy.rs | 4 - crates/secrets-mcp-local/Cargo.toml | 24 - crates/secrets-mcp-local/src/bind.rs | 212 - crates/secrets-mcp-local/src/cache.rs | 234 - crates/secrets-mcp-local/src/config.rs | 46 - crates/secrets-mcp-local/src/main.rs | 55 - crates/secrets-mcp-local/src/mcp.rs | 828 --- crates/secrets-mcp-local/src/remote.rs | 263 - crates/secrets-mcp-local/src/server.rs | 157 - crates/secrets-mcp-local/src/unlock.rs | 265 - crates/secrets-mcp/CHANGELOG.md | 57 - crates/secrets-mcp/Cargo.toml | 48 - crates/secrets-mcp/src/auth.rs | 97 - crates/secrets-mcp/src/client_ip.rs | 85 - crates/secrets-mcp/src/error.rs | 54 - crates/secrets-mcp/src/logging.rs | 381 - crates/secrets-mcp/src/main.rs | 366 - crates/secrets-mcp/src/oauth/google.rs | 116 - crates/secrets-mcp/src/oauth/mod.rs | 45 - crates/secrets-mcp/src/oauth/wechat.rs | 18 - crates/secrets-mcp/src/rate_limit.rs | 160 - crates/secrets-mcp/src/tools.rs | 1851 ----- crates/secrets-mcp/src/validation.rs | 149 - crates/secrets-mcp/src/web/account.rs | 297 - crates/secrets-mcp/src/web/assets.rs | 73 - crates/secrets-mcp/src/web/audit.rs | 104 - crates/secrets-mcp/src/web/auth.rs | 360 - crates/secrets-mcp/src/web/changelog.rs | 48 - crates/secrets-mcp/src/web/entries.rs | 1360 ---- crates/secrets-mcp/src/web/local_mcp.rs | 894 --- crates/secrets-mcp/src/web/mod.rs | 420 -- crates/secrets-mcp/static/favicon.svg | 3 - crates/secrets-mcp/static/llms.txt | 28 - crates/secrets-mcp/static/robots.txt | 31 - crates/secrets-mcp/templates/audit.html | 247 - crates/secrets-mcp/templates/changelog.html | 185 - crates/secrets-mcp/templates/dashboard.html | 975 --- crates/secrets-mcp/templates/entries.html | 1894 ----- crates/secrets-mcp/templates/home.html | 267 - crates/secrets-mcp/templates/i18n.js | 83 - crates/secrets-mcp/templates/login.html | 186 - crates/secrets-mcp/templates/trash.html | 275 - deploy/.env.example | 37 +- deploy/secrets-mcp.service | 16 +- plans/code-review-fixes-2026-04-11.md | 201 - plans/merge-code-review-fixes-2026-04-11.md | 141 - plans/metadata-search-and-entry-relations.md | 392 - plans/move-secret-management-to-view.md | 55 - plans/web-tags-filter.md | 178 - scripts/release-check.sh | 39 - scripts/setup-gitea-actions.sh | 116 +- 130 files changed, 20447 insertions(+), 21577 deletions(-) delete mode 100644 .vscode/tasks.json create mode 100644 apps/api/Cargo.toml create mode 100644 apps/api/src/bin/secrets-api-migrate.rs create mode 100644 apps/api/src/main.rs create mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/design/DESIGN.md create mode 100644 apps/desktop/design/secrets-client.pen create mode 100644 apps/desktop/src-tauri/Cargo.toml create mode 100644 apps/desktop/src-tauri/build.rs create mode 100644 apps/desktop/src-tauri/check_png_center.js create mode 100644 apps/desktop/src-tauri/gen/schemas/acl-manifests.json create mode 100644 apps/desktop/src-tauri/gen/schemas/capabilities.json create mode 100644 apps/desktop/src-tauri/gen/schemas/desktop-schema.json create mode 100644 apps/desktop/src-tauri/gen/schemas/macOS-schema.json create mode 100644 apps/desktop/src-tauri/icons/icon.png create mode 100644 apps/desktop/src-tauri/src/local_vault.rs create mode 100644 apps/desktop/src-tauri/src/main.rs create mode 100644 apps/desktop/src-tauri/src/session_api.rs create mode 100644 apps/desktop/src-tauri/tauri.conf.json create mode 100644 crates/application/Cargo.toml create mode 100644 crates/application/src/conflict.rs create mode 100644 crates/application/src/lib.rs create mode 100644 crates/application/src/sync.rs create mode 100644 crates/application/src/vault_store.rs create mode 100644 crates/client-integrations/Cargo.toml create mode 100644 crates/client-integrations/src/lib.rs create mode 100644 crates/crypto/Cargo.toml create mode 100644 crates/crypto/src/lib.rs create mode 100644 crates/desktop-daemon/Cargo.toml create mode 100644 crates/desktop-daemon/src/config.rs rename crates/{secrets-mcp-local => desktop-daemon}/src/exec.rs (64%) create mode 100644 crates/desktop-daemon/src/lib.rs create mode 100644 crates/desktop-daemon/src/main.rs rename crates/{secrets-mcp-local => desktop-daemon}/src/target.rs (64%) create mode 100644 crates/desktop-daemon/src/vault_client.rs create mode 100644 crates/device-auth/Cargo.toml create mode 100644 crates/device-auth/src/lib.rs create mode 100644 crates/domain/Cargo.toml create mode 100644 crates/domain/src/auth.rs create mode 100644 crates/domain/src/cipher.rs create mode 100644 crates/domain/src/error.rs create mode 100644 crates/domain/src/kdf.rs create mode 100644 crates/domain/src/lib.rs create mode 100644 crates/domain/src/sync.rs create mode 100644 crates/domain/src/vault_object.rs create mode 100644 crates/infrastructure-db/Cargo.toml create mode 100644 crates/infrastructure-db/src/lib.rs create mode 100644 crates/infrastructure-db/src/migrate.rs delete mode 100644 crates/secrets-core/Cargo.toml delete mode 100644 crates/secrets-core/src/audit.rs delete mode 100644 crates/secrets-core/src/config.rs delete mode 100644 crates/secrets-core/src/crypto.rs delete mode 100644 crates/secrets-core/src/db.rs delete mode 100644 crates/secrets-core/src/error.rs delete mode 100644 crates/secrets-core/src/lib.rs delete mode 100644 crates/secrets-core/src/models.rs delete mode 100644 crates/secrets-core/src/service/add.rs delete mode 100644 crates/secrets-core/src/service/api_key.rs delete mode 100644 crates/secrets-core/src/service/audit_log.rs delete mode 100644 crates/secrets-core/src/service/delete.rs delete mode 100644 crates/secrets-core/src/service/env_map.rs delete mode 100644 crates/secrets-core/src/service/export.rs delete mode 100644 crates/secrets-core/src/service/get_secret.rs delete mode 100644 crates/secrets-core/src/service/history.rs delete mode 100644 crates/secrets-core/src/service/import.rs delete mode 100644 crates/secrets-core/src/service/mod.rs delete mode 100644 crates/secrets-core/src/service/relations.rs delete mode 100644 crates/secrets-core/src/service/rollback.rs delete mode 100644 crates/secrets-core/src/service/search.rs delete mode 100644 crates/secrets-core/src/service/update.rs delete mode 100644 crates/secrets-core/src/service/user.rs delete mode 100644 crates/secrets-core/src/service/util.rs delete mode 100644 crates/secrets-core/src/taxonomy.rs delete mode 100644 crates/secrets-mcp-local/Cargo.toml delete mode 100644 crates/secrets-mcp-local/src/bind.rs delete mode 100644 crates/secrets-mcp-local/src/cache.rs delete mode 100644 crates/secrets-mcp-local/src/config.rs delete mode 100644 crates/secrets-mcp-local/src/main.rs delete mode 100644 crates/secrets-mcp-local/src/mcp.rs delete mode 100644 crates/secrets-mcp-local/src/remote.rs delete mode 100644 crates/secrets-mcp-local/src/server.rs delete mode 100644 crates/secrets-mcp-local/src/unlock.rs delete mode 100644 crates/secrets-mcp/CHANGELOG.md delete mode 100644 crates/secrets-mcp/Cargo.toml delete mode 100644 crates/secrets-mcp/src/auth.rs delete mode 100644 crates/secrets-mcp/src/client_ip.rs delete mode 100644 crates/secrets-mcp/src/error.rs delete mode 100644 crates/secrets-mcp/src/logging.rs delete mode 100644 crates/secrets-mcp/src/main.rs delete mode 100644 crates/secrets-mcp/src/oauth/google.rs delete mode 100644 crates/secrets-mcp/src/oauth/mod.rs delete mode 100644 crates/secrets-mcp/src/oauth/wechat.rs delete mode 100644 crates/secrets-mcp/src/rate_limit.rs delete mode 100644 crates/secrets-mcp/src/tools.rs delete mode 100644 crates/secrets-mcp/src/validation.rs delete mode 100644 crates/secrets-mcp/src/web/account.rs delete mode 100644 crates/secrets-mcp/src/web/assets.rs delete mode 100644 crates/secrets-mcp/src/web/audit.rs delete mode 100644 crates/secrets-mcp/src/web/auth.rs delete mode 100644 crates/secrets-mcp/src/web/changelog.rs delete mode 100644 crates/secrets-mcp/src/web/entries.rs delete mode 100644 crates/secrets-mcp/src/web/local_mcp.rs delete mode 100644 crates/secrets-mcp/src/web/mod.rs delete mode 100644 crates/secrets-mcp/static/favicon.svg delete mode 100644 crates/secrets-mcp/static/llms.txt delete mode 100644 crates/secrets-mcp/static/robots.txt delete mode 100644 crates/secrets-mcp/templates/audit.html delete mode 100644 crates/secrets-mcp/templates/changelog.html delete mode 100644 crates/secrets-mcp/templates/dashboard.html delete mode 100644 crates/secrets-mcp/templates/entries.html delete mode 100644 crates/secrets-mcp/templates/home.html delete mode 100644 crates/secrets-mcp/templates/i18n.js delete mode 100644 crates/secrets-mcp/templates/login.html delete mode 100644 crates/secrets-mcp/templates/trash.html delete mode 100644 plans/code-review-fixes-2026-04-11.md delete mode 100644 plans/merge-code-review-fixes-2026-04-11.md delete mode 100644 plans/metadata-search-and-entry-relations.md delete mode 100644 plans/move-secret-management-to-view.md delete mode 100644 plans/web-tags-filter.md diff --git a/.gitea/workflows/secrets.yml b/.gitea/workflows/secrets.yml index f331475..1a39ad9 100644 --- a/.gitea/workflows/secrets.yml +++ b/.gitea/workflows/secrets.yml @@ -1,5 +1,4 @@ -# MCP 分支:仅构建/发布 secrets-mcp(CLI 在 main 分支维护) -name: Secrets MCP — Build & Release +name: Secrets v3 CI on: push: @@ -18,7 +17,6 @@ permissions: contents: write env: - MCP_BINARY: secrets-mcp RUST_TOOLCHAIN: 1.94.0 CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 @@ -28,46 +26,14 @@ env: jobs: ci: - name: 检查 / 构建 / 发版 + name: 检查 runs-on: debian timeout-minutes: 40 - outputs: - tag: ${{ steps.ver.outputs.tag }} - version: ${{ steps.ver.outputs.version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - # ── 版本解析 ──────────────────────────────────────────────────────── - - name: 解析版本 - id: ver - run: | - version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/') - tag="secrets-mcp-${version}" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - - # 版本 bump 硬检查:若本次推送包含 crates/ 或 Cargo.toml 变更, - # 但版本号与上一提交一致,则视为未发版,直接失败。 - prev_version=$(git show HEAD^:crates/secrets-mcp/Cargo.toml 2>/dev/null | grep -m1 '^version' | sed 's/.*"\(.*\)".*/\1/' || true) - if [ -n "$prev_version" ] && [ "$version" = "$prev_version" ]; then - # 确认本次推送是否包含 crates/ 或 Cargo.toml 变更 - if git diff --name-only HEAD^ HEAD 2>/dev/null | grep -qE '^crates/|^Cargo\.toml$'; then - echo "::error::工作区包含 crates/ 或 Cargo.toml 变更,但版本号未 bump(${version} == ${prev_version})" - echo "按规则,每次代码变更必须 bump crates/secrets-mcp/Cargo.toml 中的 version。" - exit 1 - fi - fi - - if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then - echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。" - echo "tag_exists=true" >> "$GITHUB_OUTPUT" - else - echo "将创建新版本 ${tag}" - echo "tag_exists=false" >> "$GITHUB_OUTPUT" - fi - # ── Rust 工具链 ────────────────────────────────────────────────────── - name: 安装 Rust 与 musl 工具链 run: | @@ -107,76 +73,13 @@ jobs: - name: test run: cargo test --locked - # ── 构建(质量检查通过后才执行)──────────────────────────────────── - - name: 构建 secrets-mcp (musl) + - name: 构建 secrets-api run: | - cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp - strip "target/${MUSL_TARGET}/release/${MCP_BINARY}" + cargo build --release --locked -p secrets-api - - name: 上传构建产物 - uses: actions/upload-artifact@v3 - with: - name: ${{ env.MCP_BINARY }}-linux-musl - path: target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }} - retention-days: 3 - - # ── 创建 / 覆盖 Tag(构建成功后才打)─────────────────────────────── - - name: 创建 Tag + - name: 构建 secrets-desktop-daemon run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - tag="${{ steps.ver.outputs.tag }}" - if [ "${{ steps.ver.outputs.tag_exists }}" = "true" ]; then - git tag -d "$tag" 2>/dev/null || true - git push origin ":refs/tags/$tag" 2>/dev/null || true - fi - git tag -a "$tag" -m "Release $tag" - git push origin "$tag" - - # ── Release(可选,需配置 RELEASE_TOKEN)─────────────────────────── - - name: Upsert Release - if: env.RELEASE_TOKEN != '' - env: - RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - run: | - tag="${{ steps.ver.outputs.tag }}" - version="${{ steps.ver.outputs.version }}" - api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" - auth="Authorization: token $RELEASE_TOKEN" - - previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v t="$tag" '$0 != t { print; exit }') - if [ -n "$previous_tag" ]; then - changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD") - else - changes=$(git log --pretty=format:'- %s (%h)') - fi - [ -z "$changes" ] && changes="- 首次发布" - body=$(printf '## 变更日志\n\n%s' "$changes") - - # Upsert: 存在 → PATCH + 清旧 assets;不存在 → POST - release_id=$(curl -sS -H "$auth" "${api}/tags/${tag}" 2>/dev/null | jq -r '.id // empty') - if [ -n "$release_id" ]; then - curl -sS -o /dev/null -H "$auth" -H "Content-Type: application/json" \ - -X PATCH "${api}/${release_id}" \ - -d "$(jq -n --arg n "secrets-mcp ${version}" --arg b "$body" '{name:$n,body:$b,draft:false}')" - curl -sS -H "$auth" "${api}/${release_id}/assets" | \ - jq -r '.[].id' | xargs -I{} curl -sS -o /dev/null -H "$auth" -X DELETE "${api}/${release_id}/assets/{}" - echo "已更新 Release ${release_id}" - else - release_id=$(curl -fsS -H "$auth" -H "Content-Type: application/json" \ - -X POST "$api" \ - -d "$(jq -n --arg t "$tag" --arg n "secrets-mcp ${version}" --arg b "$body" \ - '{tag_name:$t,name:$n,body:$b,draft:false}')" | jq -r '.id') - echo "已创建 Release ${release_id}" - fi - - bin="target/${MUSL_TARGET}/release/${MCP_BINARY}" - archive="${MCP_BINARY}-${tag}-x86_64-linux-musl.tar.gz" - tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" - sha256sum "$archive" > "${archive}.sha256" - curl -fsS -H "$auth" -F "attachment=@${archive}" "${api}/${release_id}/assets" - curl -fsS -H "$auth" -F "attachment=@${archive}.sha256" "${api}/${release_id}/assets" - echo "Release ${tag} 已发布" + cargo build --release --locked -p secrets-desktop-daemon # ── 飞书汇总通知 ───────────────────────────────────────────────────── - name: 飞书通知 @@ -185,84 +88,14 @@ jobs: WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} run: | [ -z "$WEBHOOK_URL" ] && exit 0 - tag="${{ steps.ver.outputs.tag }}" commit="${{ github.event.head_commit.message }}" [ -z "$commit" ] && commit="${{ github.sha }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" result="${{ job.status }}" if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi - msg="secrets-mcp 构建&发版 ${icon} - 版本:${tag} + msg="secrets v3 CI ${icon} 提交:${commit} 作者:${{ github.actor }} 详情:${url}" payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}') curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" - - deploy: - name: 部署 secrets-mcp - needs: [ci] - if: | - github.ref == 'refs/heads/main' || - github.ref == 'refs/heads/feat/mcp' || - github.ref == 'refs/heads/mcp' - runs-on: debian - timeout-minutes: 10 - steps: - - name: 下载构建产物 - uses: actions/download-artifact@v3 - with: - name: ${{ env.MCP_BINARY }}-linux-musl - path: /tmp/artifact - - - name: 部署到阿里云 ECS - env: - DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} - DEPLOY_USER: ${{ vars.DEPLOY_USER }} - DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }} - run: | - if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then - echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY" - exit 0 - fi - - install -m 600 /dev/null /tmp/deploy_key - echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key - trap 'rm -f /tmp/deploy_key' EXIT - - if [ -n "$DEPLOY_KNOWN_HOSTS" ]; then - echo "$DEPLOY_KNOWN_HOSTS" > /tmp/deploy_known_hosts - ssh_opts="-o UserKnownHostsFile=/tmp/deploy_known_hosts -o StrictHostKeyChecking=yes" - else - ssh_opts="-o StrictHostKeyChecking=accept-new" - fi - - scp -i /tmp/deploy_key $ssh_opts \ - "/tmp/artifact/${MCP_BINARY}" \ - "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new" - - ssh -i /tmp/deploy_key $ssh_opts "${DEPLOY_USER}@${DEPLOY_HOST}" " - sudo mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp - sudo chmod +x /opt/secrets-mcp/secrets-mcp - sudo systemctl restart secrets-mcp - sleep 2 - sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1) - " - - - name: 飞书通知 - if: always() - env: - WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} - run: | - [ -z "$WEBHOOK_URL" ] && exit 0 - tag="${{ needs.ci.outputs.tag }}" - url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" - result="${{ job.status }}" - if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi - msg="secrets-mcp 部署 ${icon} - 版本:${tag} - 作者:${{ github.actor }} - 详情:${url}" - payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}') - curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" diff --git a/.gitignore b/.gitignore index 7d7c8ae..3da0266 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,13 @@ tmp/ client_secret_*.apps.googleusercontent.com.json node_modules/ -*.pyc \ No newline at end of file +*.pyc + +# Desktop: Tauri frontend bundle (tauri.conf.json build.frontendDist) +apps/desktop/dist/ + +# Tauri app icon pack: generated by `cargo tauri icon apps/desktop/src-tauri/icons/icon.png` +# Version control only the 1024×1024 master; regenerate the rest locally or in release builds. +apps/desktop/src-tauri/icons/** +!apps/desktop/src-tauri/icons/ +!apps/desktop/src-tauri/icons/icon.png \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 2148020..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "mcp: build", - "type": "shell", - "command": "cargo build --locked -p secrets-mcp", - "group": "build", - "options": { - "envFile": "${workspaceFolder}/.env" - } - }, - { - "label": "mcp: run", - "type": "shell", - "command": "cargo run --locked -p secrets-mcp", - "options": { - "envFile": "${workspaceFolder}/.env" - } - }, - { - "label": "test: workspace", - "type": "shell", - "command": "cargo test --workspace --locked", - "group": { "kind": "test", "isDefault": true } - }, - { - "label": "fmt: check", - "type": "shell", - "command": "cargo fmt -- --check", - "problemMatcher": [] - }, - { - "label": "clippy: workspace", - "type": "shell", - "command": "cargo clippy --workspace --locked -- -D warnings", - "problemMatcher": [] - }, - { - "label": "ci: release-check", - "type": "shell", - "command": "./scripts/release-check.sh", - "problemMatcher": [] - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index 0aba73e..1de657f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,13 @@ -# Secrets MCP — AGENTS.md +# Secrets — AGENTS.md -本仓库为 **MCP SaaS**:`secrets-core`(业务与持久化)+ `secrets-mcp`(Streamable HTTP MCP、Web、OAuth、API Key)。对外入口见 `crates/secrets-mcp`。 +本仓库当前为 **v3 桌面端架构**: + +- `apps/api`:远端 JSON API +- `apps/desktop/src-tauri`:桌面客户端 +- `crates/desktop-daemon`:本地 MCP daemon +- `crates/application` / `domain` / `infrastructure-db`:v3 业务与数据层 + +旧 `secrets-core` / `secrets-mcp` / `secrets-mcp-local` 已移除,不再作为开发入口。 ## 版本控制 @@ -23,202 +30,14 @@ | 拉取远端 | `jj git fetch` | ### 注意事项 -- 本仓库为**纯 jj 模式**,无 `.git` 目录;本地不要使用 `git` 命令 -- CI/CD(Gitea Actions)仍通过 Git 协议拉取代码,Runner 侧自动使用 `git`,无需修改 -- 检查标签是否存在时使用 `jj log --no-graph --revisions "tag(${tag})"` 而非 `git rev-parse` -## 提交 / 推送硬规则(优先于下文) - -**每次提交和推送前必须执行以下检查,无论是否明确「发版」:** - -1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。 -2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`jj tag list`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`。 -3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`。 - -## 项目结构 - -``` -secrets/ - Cargo.toml - crates/ - secrets-core/ # db / crypto / models / audit / service - secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard;CHANGELOG.md → /changelog - scripts/ - release-check.sh - setup-gitea-actions.sh - .gitea/workflows/secrets.yml - .vscode/tasks.json -``` - -## 数据库 - -- **建议库名**:`secrets-mcp`(专用实例,与历史库名区分)。 -- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。 -- **表**:`entries`(含 `user_id`)、`secrets`、`entries_history`、`secrets_history`、`audit_log`、`users`、`oauth_accounts`,首次连接 **auto-migrate**(`secrets-core` 的 `migrate`)。 -- **Web 会话**:与上项 **同一数据库 URL**;`secrets-mcp` 启动时对 tower-sessions 的 PostgreSQL 存储 **auto-migrate**(会话表与业务表共存于该实例,无需第二套连接串)。 - -### 表结构(摘录) - -```sql -entries ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID, -- 多租户:NULL=遗留行;非空=归属用户 - folder VARCHAR(128) NOT NULL DEFAULT '', - type VARCHAR(64) NOT NULL DEFAULT '', - name VARCHAR(256) NOT NULL, - notes TEXT NOT NULL DEFAULT '', - tags TEXT[] NOT NULL DEFAULT '{}', - metadata JSONB NOT NULL DEFAULT '{}', - version BIGINT NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) --- 唯一:UNIQUE(user_id, folder, name) WHERE user_id IS NOT NULL; --- UNIQUE(folder, name) WHERE user_id IS NULL(单租户遗留) -``` - -```sql -secrets ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID, - name VARCHAR(256) NOT NULL, - type VARCHAR(64) NOT NULL DEFAULT 'text', - encrypted BYTEA NOT NULL DEFAULT '\x', - version BIGINT NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) --- 唯一:UNIQUE(user_id, name) WHERE user_id IS NOT NULL -``` - -```sql -entry_secrets ( - entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE, - sort_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY(entry_id, secret_id) -) -``` - -### users / oauth_accounts - -```sql -users ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - email VARCHAR(256), - name VARCHAR(256) NOT NULL DEFAULT '', - avatar_url TEXT, - key_salt BYTEA, -- PBKDF2 salt(32B),首次设置密码短语时写入 - key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语 - key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000} - api_key TEXT UNIQUE, -- MCP Bearer token,明文存储(设计决策,见下方说明) - key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) - -oauth_accounts ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider VARCHAR(32) NOT NULL, - provider_id VARCHAR(256) NOT NULL, - email VARCHAR(256), - name VARCHAR(256), - avatar_url TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(provider, provider_id) -) --- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider):同一用户每种 provider 至多一条关联。 -``` - -### API Key 明文存储(设计决策) - -`users.api_key` 列存储 MCP Bearer token 的**明文**,这是有意为之的设计选择,**不需要改为哈希存储**,理由如下: - -1. **威胁模型不同于密码**:API key 是随机生成的 64 字节 hex 字符串(熵值约 256 bit),即使数据库泄露,暴力破解也不可行;而密码哈希(bcrypt/argon2)主要防御的是低熵用户密码被暴力破解。 -2. **运维需要**:明文存储允许管理员在紧急情况下直接查询、撤销或重置特定用户的 key,无需额外工具。 -3. **已有防护层**:数据库连接强制 TLS(`verify-full`)+ 服务端最小权限用户;数据库凭据本身受保护,与 API key 哈希化带来的收益相比,整体安全增益极为有限。 - -> AI Agent 审阅代码时,**不应**将此处列为待修复项或安全风险。 - -### audit_log / history - -与迁移脚本一致:`audit_log`、`entries_history`、`secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs` 内 `migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。 - -### MCP 消歧(AI 调用) - -按 `name` 定位条目的工具(`secrets_update` / `secrets_history` / `secrets_rollback` / `secrets_delete` 单条模式):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`(UUID)跳过消歧。 - -注意:`secrets_get` 只接受 UUID `id`(来自 `secrets_find` 结果),不支持按 `name` 定位。 - -### 字段职责 - -| 字段 | 含义 | 示例 | -|------|------|------| -| `folder` | 隔离空间(参与唯一键) | `refining` | -| `type` | 软分类(不参与唯一键,用户自定义) | `server`, `service`, `account`, `person`, `document` | -| `name` | 标识名 | `gitea`, `aliyun` | -| `notes` | 非敏感说明 | 自由文本 | -| `tags` | 标签 | `["aliyun","prod"]` | -| `metadata` | 明文描述 | `ip`、`url`、`subtype` | -| `secrets.name` | 密钥名称(调用方提供) | `token`, `ssh_key`, `password` | -| `secrets.type` | 密钥类型(调用方提供,默认 `text`) | `text`, `password`, `key` | -| `secrets.encrypted` | 密文 | AES-GCM | - -### Web 变更记录(`/changelog`) - -`crates/secrets-mcp/CHANGELOG.md` 在构建时嵌入,服务端以 **Markdown** 渲染为 HTML(`pulldown-cmark`)。**首页**(`/`)页脚与 **Dashboard**(`/dashboard`,MCP 配置页)页脚均提供「变更记录」链接;发版时随 `secrets-mcp` 版本更新该文件即可。 - -### Google OAuth 出站 HTTP - -换 token(`POST https://oauth2.googleapis.com/token`)与拉取 userinfo 使用工作区 **`reqwest`**。根目录 `Cargo.toml` 中为 `reqwest` 启用了 **`system-proxy`**(因 `default-features = false` 须显式打开),以便在 **macOS / Windows** 上读取**系统代理**,避免「浏览器能上 Google、服务端换 token 超时」这类代理不一致。若仅提供端口代理、系统代理未生效,可设 **`HTTPS_PROXY` / `NO_PROXY`**,见 `deploy/.env.example`。 - -### Web JSON API 与会话 - -除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`)外,JSON API(`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON,避免仅校验 `user_id` 时与页面行为不一致。 - -### Web 条目页表格列(`/entries`) - -列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。 - -筛选栏支持查询参数 **`tags`**(逗号分隔,多标签 **AND**,语义同 `SearchParams.tags` / `tags @> ARRAY[...]`);分页与 folder 标签计数与当前筛选一致。 - -### 导出 / 导入文件 - -JSON/TOML/YAML 导出可在每条目上包含 `secret_types`(secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`;**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。 - -### 共享密钥(N:N 关联) - -多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。 -添加条目时通过 `link_secret_names` 参数指定要关联的已有 secret name(按 `(user_id, name)` 精确匹配)。 -删除 entry 时仅解除关联,secret 本身若仍被引用则保留;不再被任何 entry 引用时自动清理。 - -## 代码规范 - -- 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()`。 -- 异步:`tokio` + `sqlx` async。 -- SQL:`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。 -- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var`、`?var`、`var`),否则用显式形式(`field = %expr`)。 -- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。 -- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。 -- MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。 - -## 生产 CORS - -生产环境 CORS 使用显式请求头白名单(`build_cors_layer`),而非 `allow_headers(Any)`, -因为 `tower-http` 禁止 `allow_credentials(true)` 与 `allow_headers(Any)` 同时使用。 - -**维护约束**:若 MCP 协议或客户端新增自定义请求头,必须同步更新 `production_allowed_headers()`。 -当前允许的请求头:`Authorization`、`Content-Type`、`X-Encryption-Key`、`mcp-session-id`、`x-mcp-session`。 +- 本仓库为纯 `jj` 模式,本地不要使用 `git` 命令。 +- CI Runner 侧仍可能使用 `git` 拉代码,这不影响本地开发。 +- 检查 tag 是否存在时,使用 `jj log --no-graph --revisions "tag(${tag})"`。 ## 提交前检查 -```bash -./scripts/release-check.sh -``` - -或手动: +每次提交前至少运行: ```bash cargo fmt -- --check @@ -226,41 +45,197 @@ cargo clippy --locked -- -D warnings cargo test --locked ``` -发版前确认未重复 tag: +也可以直接运行: ```bash -grep '^version' crates/secrets-mcp/Cargo.toml -jj tag list +./scripts/release-check.sh ``` -## CI/CD +## 项目结构 -- **触发**:任意分支 `push`,且路径含 `crates/**`、`deploy/**`、根目录 `Cargo.toml`、`Cargo.lock`、`.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。 -- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-`:若远端已存在同名 tag,CI 会先删后于**当前提交**重建并推送(覆盖式发版)。 -- **质量与构建**:`fmt` / `clippy --locked` / `test --locked` → `x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`。 -- **Release(可选)**:`secrets.RELEASE_TOKEN`(Gitea PAT)用于通过 API **创建或更新**该 tag 的 Release(非 draft)、上传 `tar.gz` + `.sha256`;未配置则跳过 API Release,仅 tag + 构建。 -- **部署(可选)**:仅 `main`、`feat/mcp`、`mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER`、`secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow,用 `deploy/.env.example` 在目标机配置。 -- **Secrets 写法**:Actions **secrets 须为原始值**(PEM、PAT 明文),**勿** base64;否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。 -- **通知**:`vars.WEBHOOK_URL`(可选,飞书)。 +```text +secrets/ + Cargo.toml + apps/ + api/ # 远端 JSON API + desktop/src-tauri/ # 桌面端 + crates/ + application/ # v3 应用服务 + client-integrations/ # Cursor / Claude Code 配置注入 + crypto/ # 通用加密辅助 + desktop-daemon/ # 本地 MCP daemon + device-auth/ # 设备登录 / Desktop OAuth 辅助 + domain/ # v3 领域模型 + infrastructure-db/ # 数据库与迁移 + deploy/ + scripts/ + .gitea/workflows/ + .vscode/tasks.json +``` -## 环境变量(secrets-mcp) +## 数据库 -| 变量 | 说明 | -|------|------| -| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 | -| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`)。 | -| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径。 | -| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 | -| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 | -| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式。 | -| `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 | -| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 | -| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | -| `RUST_LOG` | 如 `secrets_mcp=debug`。 | -| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 | -| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 | -| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 | -| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 | -| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP。 | +- 建议数据库名:`secrets-v3` +- 连接串:`SECRETS_DATABASE_URL` +- 首次连接会自动运行 `secrets-infrastructure-db::migrate_current_schema` -> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。 +当前 v3 主要表: + +- `users` +- `oauth_accounts` +- `devices` +- `device_login_tokens` +- `auth_events` +- `vault_objects` +- `vault_object_revisions` + +### 当前模型约束 + +- 服务端只保存同步所需的密文对象与版本信息 +- 搜索、详情、reveal、history 主要在 desktop 本地 vault 中完成 +- 删除通过对象级 `deleted_at` / tombstone 传播 +- 历史服务端保留在 `vault_object_revisions`,本地另有 `vault_object_history` + +### 字段职责 + +| 字段 | 含义 | 示例 | +|------|------|------| +| `object_id` | 同步对象标识 | `UUID` | +| `object_kind` | 当前对象类别 | `cipher` | +| `revision` | 对象版本号 | `12` | +| `cipher_version` | 密文封装版本 | `1` | +| `ciphertext` | 密文对象载荷 | AES-GCM 密文 | +| `content_hash` | 密文内容摘要 | `sha256:...` | +| `deleted_at` | 对象删除时间 | `2026-04-14T12:00:00Z` | + +## Google 登录 + +当前登录流为 **Google Desktop OAuth**: + +- 桌面端使用系统浏览器拉起 Google 授权 +- 使用本地 loopback callback +- 使用 `PKCE` +- 桌面端换取 Google token 后调用 API 的桌面登录接口 +- API 校验 Google userinfo 后发放本地 device token + +桌面端优先读取: + +- `GOOGLE_OAUTH_CLIENT_FILE` + +默认开发文件名: + +- `client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json` + +## MCP + +本地 MCP 入口由 `crates/desktop-daemon` 提供,默认地址: + +```text +http://127.0.0.1:9515/mcp +``` + +当前暴露的工具: + +- `secrets_entry_find` +- `secrets_entry_get` +- `secrets_entry_add` +- `secrets_entry_update` +- `secrets_entry_delete` +- `secrets_entry_restore` +- `secrets_secret_add` +- `secrets_secret_update` +- `secrets_secret_delete` +- `secrets_secret_history` +- `secrets_secret_rollback` +- `target_exec` + +当前不保留: + +- `secrets_env_map` + +兼容别名: + +- `secrets_find` +- `secrets_add` +- `secrets_update` + +### `target_exec` + +`target_exec` 会显式读取 entry 当前 secrets 的真实值,并从 metadata / secrets 派生标准环境变量,例如: + +- `TARGET_ENTRY_ID` +- `TARGET_NAME` +- `TARGET_FOLDER` +- `TARGET_TYPE` +- `TARGET_HOST` +- `TARGET_PORT` +- `TARGET_USER` +- `TARGET_BASE_URL` +- `TARGET_API_KEY` +- `TARGET_TOKEN` +- `TARGET_SSH_KEY` + +## 桌面端 + +桌面端当前支持: + +- Google 登录 +- 自动写入 `Cursor` / `Claude Code` 的 `mcp.json` +- 新建条目 +- 搜索、按 type 筛选 +- 右侧原地编辑 +- secret 新增、编辑、删除 +- secret 明文显示 / 复制 +- secret 历史查看与回滚 +- 删除到最近删除与恢复 +- 登录态仅在当前 desktop 进程内有效,不做自动恢复登录 +- desktop 进程退出后,本地 daemon 所有工具不可用 + +### 配置注入 + +桌面端会把本地 daemon 配置写入: + +- `~/.cursor/mcp.json` +- `~/.claude/mcp.json` + +写入策略: + +- 保留现有其它 `mcpServers` +- 仅覆盖同名 `secrets` 节点 + +### 图标与前端 dist(本地 / CI) + +版本库为减小噪音,**不提交** Tauri 生成的多尺寸图标包,以及 **`apps/desktop/dist/`** 前端打包目录(见根目录 `.gitignore`)。 + +- **图标**:仅跟踪 `apps/desktop/src-tauri/icons/icon.png` 作为源图(建议 **1024×1024** PNG)。检出代码后,若需要完整 `icons/`(例如打包、验证窗口/托盘图标),在 **`apps/desktop/src-tauri`** 下执行: + + ```bash + cd apps/desktop/src-tauri + cargo tauri icon icons/icon.png + ``` + + 需已安装 **Tauri CLI**(例如 `cargo install tauri-cli`,或与项目一致的 `cargo-tauri` 版本)。 + +- **前端 dist**:`tauri.conf.json` 中 `build.frontendDist` 指向 `../dist`。本地或 CI 在运行 `cargo tauri dev` / `cargo tauri build` 前,需先按项目约定生成或同步 **`apps/desktop/dist/`** 内容;流水线构建桌面端时,在 Tauri 步骤之前加入对应的前端产物步骤即可。 + +## 代码规范 + +- 业务层优先使用 `anyhow::Result` +- 避免生产路径 `unwrap()` +- 使用 `tokio` + `sqlx` async +- SQL 使用参数绑定,不要手拼用户输入 +- 运维日志使用 `tracing` +- 变更后优先跑最小必要验证,不要只改不测 + +## CI / 脚本 + +- `.gitea/workflows/secrets.yml` 现在是 v3 workspace 级 CI +- `scripts/release-check.sh` 只做 workspace 质量检查 +- `deploy/.env.example` 反映当前 v3 API / daemon / desktop 登录配置 + +## 安全约束 + +- 不要把 Google `client_secret` 提交到受版本控制的配置文件中 +- 不要把 device token、数据库密码、真实生产密钥提交入库 +- 数据库生产环境优先使用 `verify-full` +- AI 审查时,不要把“随机高熵 token 明文存储”机械地当成密码学问题处理,必须结合当前架构和威胁模型判断 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8339501..20f055e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,11 +45,12 @@ cargo test --locked ## 发版规则 -涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认需要发版。 +当前仓库已切换到 v3 架构,不再围绕 `secrets-mcp` 做单独发版。 -1. 检查 `crates/secrets-mcp/Cargo.toml` 的 `version` -2. 运行 `jj tag list` 确认对应 tag 是否已存在 -3. 若 tag 已存在且有代码变更,**必须 bump 版本**并 `cargo build` 同步 `Cargo.lock` -4. 通过 release-check 后再提交 +提交前请至少保证: -详见 [AGENTS.md](AGENTS.md) 的「提交 / 推送硬规则」章节。 +1. `cargo fmt -- --check` +2. `cargo clippy --locked -- -D warnings` +3. `cargo test --locked` + +详见 [AGENTS.md](AGENTS.md) 中最新的仓库说明。 diff --git a/Cargo.lock b/Cargo.lock index c86ee84..46af67f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -46,6 +52,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -68,45 +89,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "askama" -version = "0.13.1" +name = "argon2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn", -] - -[[package]] -name = "askama_parser" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow 0.7.15", + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", ] [[package]] @@ -117,7 +108,30 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", ] [[package]] @@ -194,27 +208,10 @@ dependencies = [ ] [[package]] -name = "axum-extra" -version = "0.10.3" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" -dependencies = [ - "axum", - "axum-core", - "bytes", - "futures-util", - "headers", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "serde_core", - "tower-layer", - "tower-service", - "tracing", -] +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" @@ -229,14 +226,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "basic-toml" -version = "0.1.10" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "serde", + "bit-vec", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -246,6 +255,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -255,12 +273,48 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -272,6 +326,76 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] [[package]] name = "cc" @@ -283,6 +407,33 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -317,7 +468,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -330,6 +481,16 @@ dependencies = [ "inout", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -345,13 +506,18 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "percent-encoding", "time", "version_check", ] @@ -366,12 +532,46 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -405,6 +605,24 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -431,6 +649,56 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ctr" version = "0.9.2" @@ -460,7 +728,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -471,21 +739,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "syn 2.0.117", ] [[package]] @@ -509,6 +763,40 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -521,6 +809,39 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -529,7 +850,45 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", ] [[package]] @@ -538,6 +897,36 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -553,12 +942,43 @@ dependencies = [ "serde", ] +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -597,12 +1017,41 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -614,6 +1063,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -626,6 +1081,33 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -635,6 +1117,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -702,7 +1194,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -717,12 +1209,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - [[package]] name = "futures-util" version = "0.3.32" @@ -740,6 +1226,114 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -751,12 +1345,14 @@ dependencies = [ ] [[package]] -name = "getopts" -version = "0.2.24" +name = "getrandom" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ - "unicode-width", + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", ] [[package]] @@ -768,7 +1364,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -811,33 +1407,158 @@ dependencies = [ ] [[package]] -name = "governor" -version = "0.10.4" +name = "gio" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" dependencies = [ - "cfg-if", - "dashmap", - "futures-sink", - "futures-timer", + "futures-channel", + "futures-core", + "futures-io", "futures-util", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "nonzero_ext", - "parking_lot", - "portable-atomic", - "quanta", - "rand 0.9.2", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", "smallvec", - "spinning_top", - "web-time", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" @@ -855,11 +1576,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "hashlink" @@ -871,28 +1587,10 @@ dependencies = [ ] [[package]] -name = "headers" +name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "heck" @@ -933,6 +1631,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + [[package]] name = "http" version = "1.4.0" @@ -1023,7 +1743,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1054,7 +1774,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1066,6 +1786,16 @@ dependencies = [ "cc", ] +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1180,6 +1910,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1192,6 +1933,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inout" version = "0.1.4" @@ -1223,6 +1973,73 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1233,6 +2050,51 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1248,12 +2110,46 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libm" version = "0.2.16" @@ -1266,7 +2162,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", "redox_syscall 0.7.3", @@ -1278,16 +2174,11 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.1" @@ -1301,7 +2192,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", - "serde", ] [[package]] @@ -1316,6 +2206,48 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1325,6 +2257,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "matchit" version = "0.8.4" @@ -1347,12 +2285,31 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1360,15 +2317,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] [[package]] -name = "nonzero_ext" -version = "0.3.0" +name = "muda" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "nu-ansi-term" @@ -1431,6 +2445,151 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1443,6 +2602,37 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -1469,7 +2659,18 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -1493,6 +2694,193 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1538,6 +2926,32 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1550,12 +2964,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.4" @@ -1580,6 +2988,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1587,9 +3001,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.5+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1600,37 +3073,12 @@ dependencies = [ ] [[package]] -name = "pulldown-cmark" -version = "0.13.3" +name = "quick-xml" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ - "bitflags", - "getopts", "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", ] [[package]] @@ -1647,7 +3095,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1668,7 +3116,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1709,6 +3157,20 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + [[package]] name = "rand" version = "0.8.5" @@ -1741,6 +3203,16 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -1761,6 +3233,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1786,21 +3267,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] -name = "raw-cpuid" -version = "11.6.0" +name = "rand_hc" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" dependencies = [ - "bitflags", + "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1809,7 +3305,18 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", ] [[package]] @@ -1829,7 +3336,19 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1855,7 +3374,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1885,11 +3404,45 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.6", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1911,7 +3464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -1922,11 +3475,11 @@ dependencies = [ "pin-project-lite", "rand 0.10.0", "rmcp-macros", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "sse-stream", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -1945,26 +3498,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", -] - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", + "syn 2.0.117", ] [[package]] @@ -1994,16 +3528,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] -name = "rustix" -version = "1.1.4" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "semver", ] [[package]] @@ -2053,6 +3583,42 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive 0.8.22", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -2062,11 +3628,23 @@ dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.1", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "schemars_derive" version = "1.2.1" @@ -2076,7 +3654,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -2086,83 +3664,182 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "secrets-core" +name = "secrets-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "dotenvy", + "reqwest 0.12.28", + "secrets-application", + "secrets-device-auth", + "secrets-domain", + "secrets-infrastructure-db", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "secrets-application" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "secrets-domain", + "serde", + "serde_json", + "sqlx", + "uuid", +] + +[[package]] +name = "secrets-client-integrations" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", +] + +[[package]] +name = "secrets-crypto" version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", - "chrono", "hex", "rand 0.10.0", - "serde", - "serde_json", - "serde_yaml", - "sqlx", - "tempfile", - "thiserror", - "tokio", - "toml", - "tracing", - "uuid", ] [[package]] -name = "secrets-mcp" -version = "0.6.0" +name = "secrets-desktop" +version = "3.0.0" dependencies = [ "anyhow", - "askama", "axum", - "axum-extra", + "base64 0.22.1", "chrono", - "dotenvy", - "governor", - "http", - "pulldown-cmark", - "rand 0.10.0", - "reqwest", - "rmcp", - "schemars", - "secrets-core", + "hex", + "reqwest 0.12.28", + "secrets-client-integrations", + "secrets-crypto", + "secrets-device-auth", + "secrets-domain", "serde", "serde_json", + "sha2", "sqlx", - "time", + "tauri", + "tauri-build", "tokio", - "tower", - "tower-http", - "tower-sessions", - "tower-sessions-sqlx-store-chrono", - "tracing", - "tracing-subscriber", "url", - "urlencoding", "uuid", ] [[package]] -name = "secrets-mcp-local" +name = "secrets-desktop-daemon" version = "0.1.0" dependencies = [ "anyhow", "axum", "dotenvy", - "reqwest", - "secrets-core", + "reqwest 0.12.28", + "rmcp", + "secrets-device-auth", "serde", "serde_json", "tokio", "tracing", "tracing-subscriber", +] + +[[package]] +name = "secrets-device-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "hex", + "rand 0.10.0", + "sha2", "url", "uuid", ] +[[package]] +name = "secrets-domain" +version = "0.1.0" +dependencies = [ + "argon2", + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "secrets-infrastructure-db" +version = "0.1.0" +dependencies = [ + "anyhow", + "dotenvy", + "sqlx", + "tracing", + "uuid", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2174,6 +3851,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2191,7 +3880,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2202,7 +3891,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2229,6 +3918,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2251,16 +3960,75 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_with" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" dependencies = [ - "indexmap", - "itoa", - "ryu", "serde", - "unsafe-libyaml", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", ] [[package]] @@ -2320,6 +4088,24 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -2345,6 +4131,54 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "spin" version = "0.9.8" @@ -2354,15 +4188,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - [[package]] name = "spki" version = "0.7.3" @@ -2392,7 +4217,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -2405,7 +4230,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -2415,7 +4240,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2434,7 +4259,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2445,7 +4270,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck", + "heck 0.5.0", "hex", "once_cell", "proc-macro2", @@ -2457,7 +4282,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -2469,8 +4294,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -2500,7 +4325,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2513,8 +4338,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -2539,7 +4364,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2565,7 +4390,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2590,6 +4415,55 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -2613,6 +4487,28 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2641,7 +4537,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2650,8 +4546,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2666,16 +4562,315 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "system-deps" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "fastrand", - "getrandom 0.4.2", + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", "once_cell", - "rustix", - "windows-sys 0.61.2", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", ] [[package]] @@ -2684,7 +4879,18 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2695,7 +4901,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2787,7 +4993,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2826,17 +5032,47 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.7+spec-1.1.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ - "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.0", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2848,6 +5084,42 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + [[package]] name = "toml_parser" version = "1.0.10+spec-1.1.0" @@ -2879,40 +5151,22 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" -dependencies = [ - "axum-core", - "cookie", - "futures-util", - "http", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", "http-body", - "http-body-util", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2927,73 +5181,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-sessions" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" -dependencies = [ - "async-trait", - "http", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core", - "tower-sessions-memory-store", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" -dependencies = [ - "async-trait", - "axum-core", - "base64", - "futures", - "http", - "parking_lot", - "rand 0.8.5", - "serde", - "serde_json", - "thiserror", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-memory-store" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" -dependencies = [ - "async-trait", - "time", - "tokio", - "tower-sessions-core", -] - -[[package]] -name = "tower-sessions-sqlx-store-chrono" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b295c8fc08db03246e92773c5e10119b72db6bc4240112135bebb0e49670804f" -dependencies = [ - "async-trait", - "axum", - "chrono", - "rmp-serde", - "sqlx", - "thiserror", - "time", - "tower-sessions-core", -] - [[package]] name = "tracing" version = "0.1.44" @@ -3014,7 +5201,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3056,12 +5243,40 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" @@ -3069,10 +5284,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] -name = "unicase" -version = "2.9.0" +name = "unic-char-property" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] [[package]] name = "unicode-bidi" @@ -3102,10 +5352,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] -name = "unicode-width" -version = "0.2.2" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -3123,12 +5373,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -3145,13 +5389,26 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] -name = "urlencoding" -version = "2.1.3" +name = "urlpattern" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" @@ -3183,12 +5440,48 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3198,6 +5491,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3274,7 +5573,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3304,7 +5603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -3322,15 +5621,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -3354,6 +5666,62 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3372,6 +5740,42 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3398,12 +5802,71 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3412,9 +5875,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3425,7 +5899,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3436,24 +5910,49 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3462,7 +5961,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3471,7 +5979,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -3492,6 +6009,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3507,7 +6033,22 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3547,7 +6088,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3558,6 +6099,30 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3576,6 +6141,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3594,6 +6165,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3624,6 +6201,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3642,6 +6225,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3660,6 +6249,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3678,6 +6273,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3698,18 +6299,37 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] [[package]] name = "wit-bindgen" @@ -3727,7 +6347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -3738,10 +6358,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap", + "heck 0.5.0", + "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3757,7 +6377,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3769,8 +6389,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.0", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -3789,7 +6409,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -3805,6 +6425,71 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wry" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.1" @@ -3824,7 +6509,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3845,7 +6530,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3865,7 +6550,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3905,7 +6590,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ba80ce9..8addbcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,14 @@ [workspace] members = [ - "crates/secrets-core", - "crates/secrets-mcp", - "crates/secrets-mcp-local", + "apps/api", + "apps/desktop/src-tauri", + "crates/application", + "crates/client-integrations", + "crates/crypto", + "crates/desktop-daemon", + "crates/device-auth", + "crates/domain", + "crates/infrastructure-db", ] resolver = "2" @@ -14,7 +20,7 @@ edition = "2024" tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] } # Database -sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } +sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "uuid", "json", "chrono"] } # Serialization serde = { version = "^1.0.228", features = ["derive"] } @@ -26,12 +32,13 @@ toml = "^1.0.7" aes-gcm = "^0.10.3" sha2 = "^0.10.9" rand = "^0.10.0" +hex = "0.4" # Utils anyhow = "^1.0.102" thiserror = "^2" chrono = { version = "^0.4.44", features = ["serde"] } -uuid = { version = "^1.22.0", features = ["serde"] } +uuid = { version = "^1.22.0", features = ["serde", "v4"] } tracing = "^0.1" tracing-subscriber = { version = "^0.3", features = ["env-filter"] } dotenvy = "^0.15" @@ -39,3 +46,9 @@ dotenvy = "^0.15" # HTTP # system-proxy:与浏览器一致,读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等) reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] } +axum = "0.8" +http = "1" +url = "2" +rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] } +tauri = { version = "2", features = [] } +tauri-build = { version = "2", features = [] } diff --git a/README.md b/README.md index ea6faa7..12f99f7 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,50 @@ -# secrets-mcp +# Secrets -Workspace:**`secrets-core`** + **`secrets-mcp`**(HTTP Streamable MCP + Web)+ **`secrets-mcp-local`**(可选:本机 MCP gateway)。多租户密钥与元数据存 PostgreSQL;用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。 +这是 v3 架构的仓库,当前主路径已经收敛为: -## 安装 +- `apps/api`:远端 JSON API +- `apps/desktop/src-tauri`:桌面客户端 +- `crates/desktop-daemon`:本地 MCP 入口 +- `crates/application` / `domain` / `infrastructure-db`:业务与数据层 + +## 本地开发 ```bash -cargo build --release -p secrets-mcp -# 产物: target/release/secrets-mcp +cp deploy/.env.example .env + +# 远端 API +cargo run -p secrets-api --bin secrets-api + +# 本地 daemon +cargo run -p secrets-desktop-daemon + +# 桌面客户端 +cargo run -p secrets-desktop ``` -```bash -cargo build --release -p secrets-mcp-local -# 产物: target/release/secrets-mcp-local(本机 MCP gateway,见下节) -``` +## 当前能力 -发版产物见 Gitea Release(tag:`secrets-mcp-`,Linux musl 预编译);其它平台本地 `cargo build`。 +- 桌面端使用系统浏览器完成 Google Desktop OAuth 登录 +- 登录成功后向 API 注册设备,并在当前桌面进程内维护登录会话 +- 本地 daemon 提供显式拆分的 MCP 工具: + - `secrets_entry_find` / `secrets_entry_get` + - `secrets_entry_add` / `secrets_entry_update` / `secrets_entry_delete` / `secrets_entry_restore` + - `secrets_secret_add` / `secrets_secret_update` / `secrets_secret_delete` + - `secrets_secret_history` / `secrets_secret_rollback` + - `target_exec` +- 保留兼容别名:`secrets_find` / `secrets_add` / `secrets_update` +- 桌面端会自动把本地 daemon MCP 配置写入 `Cursor` 与 `Claude Code` +- 桌面端支持条目新建、搜索、按 type 筛选、元数据编辑、最近删除与恢复 +- 桌面端支持 secret 新增、编辑、删除、明文显示、真实复制、历史查看与回滚 +- 不保留 `secrets_env_map` +- 不做自动恢复登录;重启 app 后必须重新登录 -## 环境变量与本地运行 - -复制 `deploy/.env.example` 为项目根目录 `.env`(已在 `.gitignore`),或导出同名变量: - -| 变量 | 说明 | -|------|------| -| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(推荐使用域名,例如 `db.refining.ltd`,避免直连 IP)。 | -| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`),避免回退到弱 TLS 模式。 | -| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径(如 `/etc/secrets/pg-ca.crt`)。 | -| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer`、`disable`、`allow`、`require`)。 | -| `BASE_URL` | 对外访问基址;OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 | -| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 | -| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。换 token 须访问 `oauth2.googleapis.com`:工作区 **`reqwest` 已启用 `system-proxy`**,与浏览器一致可走 macOS/Windows **系统代理**(如 Clash 系统代理模式)。 | -| `HTTPS_PROXY` / `NO_PROXY` | 可选。仅当系统代理未被进程识别、又需走本地端口代理时设置;示例见 [`deploy/.env.example`](deploy/.env.example)。 | -| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 | -| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 | -| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 | -| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 | -| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 | -| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 | -| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 | -| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP;仅在反代环境下启用。 | +## 提交前检查 ```bash -cargo run -p secrets-mcp -``` - -生产推荐示例(PostgreSQL TLS): - -```bash -SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp -SECRETS_DATABASE_SSL_MODE=verify-full -SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt -SECRETS_ENV=production -``` - -- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 Dashboard(MCP)页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。 -- **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer ` + `X-Encryption-Key: ` 请求头(读密文工具须带密钥)。 - -### 本地 MCP gateway(`secrets-mcp-local`) - -`secrets-mcp-local` 现在是**独立的本地 MCP 入口**,不再依赖把远程 `/mcp` 原样透传到本机。它始终能完成 MCP `initialize` / `tools/list`,但会按状态暴露不同工具面: - -- `bootstrap`:尚未绑定或尚未解锁,只暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info` -- `pendingUnlock`:远端授权已完成,但本地仍未完成 passphrase 解锁;仍只暴露 bootstrap 工具 -- `ready`:绑定 + 解锁均完成,额外暴露 `secrets_find`、`secrets_search`、`secrets_history`、`secrets_overview`、`secrets_delete(dry_run)`、`target_exec` - -上线流程: -1. 启动 `secrets-mcp-local` -2. 在浏览器打开本地首页 `http://127.0.0.1:9316/` -3. 点击“开始绑定”,打开页面给出的 `approve_url` -4. 在远端网页确认授权后,返回本地首页等待自动进入解锁阶段 -5. 在本地页面或 `/unlock` 完成浏览器内 PBKDF2 派生、`key_check` 校验与本地解锁 -6. 之后将 Cursor 等客户端的 MCP URL 配为 `http://127.0.0.1:9316/mcp` - -这套流程下,Cursor 会先稳定连上 local MCP;未就绪时 AI 只能看到 bootstrap 工具,因此会明确告诉用户去打开本地 onboarding 页面或 `approve_url`,不会再因为 `401` 被误判成“连接失败”。 - -运行时说明: -- local gateway 的业务数据面已切到远端 JSON HTTP API:`find/search/history/overview/delete-preview/decrypt` 直接走 `/api/local-mcp/...` -- `target_exec` 首次执行某个目标时,建议同时传入 `secrets_find/search` 返回的目标摘要;local gateway 会按 `entry_id` 缓存解析后的执行上下文,后续同一目标可复用而不必重新读取密钥 -- 远端 `key_version` 变化时,本地会自动从 `ready` 回退到 `pendingUnlock` -- 远端 API key 已失效或绑定用户不存在时,本地会自动清除 bound 状态并重新回到 `bootstrap` - -`target_exec` 运行时会注入一组标准环境变量,例如: -- `TARGET_ENTRY_ID`、`TARGET_NAME`、`TARGET_FOLDER`、`TARGET_TYPE` -- `TARGET_HOST`、`TARGET_PORT`、`TARGET_USER`、`TARGET_BASE_URL` -- `TARGET_API_KEY`、`TARGET_TOKEN`、`TARGET_SSH_KEY` -- `TARGET_META_` 与 `TARGET_SECRET_`(对 metadata / secret 字段名做大写与下划线归一化) - -典型用法: -- 先 `secrets_find` 找到目标服务器,再用 `target_exec` 执行 `ssh -i <(printf '%s' \"$TARGET_SSH_KEY\") \"$TARGET_USER@$TARGET_HOST\" 'df -h'` -- 先 `secrets_search` 找到 API 服务条目,再用 `target_exec` 执行 `curl -H \"Authorization: Bearer $TARGET_API_KEY\" \"$TARGET_BASE_URL/health\"` - -本地状态行为: -- `POST /local/lock`:仅清除本地解锁缓存,保留绑定 -- `POST /local/unbind`:同时清除本地绑定与解锁状态 -- `GET /local/status`:返回 `bootstrap` / `pendingUnlock` / `ready`、待确认绑定会话、缓存目标数、`onboarding_url` / `unlock_url` - -| 变量 | 说明 | -|------|------| -| `SECRETS_REMOTE_BASE_URL` | **必填**。远程 Web 基址,例如 `https://secrets.example.com`。 | -| `SECRETS_MCP_LOCAL_BIND` | 可选。监听地址,默认 `127.0.0.1:9316`。 | -| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(`/local/unlock/complete` 可传 `ttl_secs` 覆盖)。 | -| `SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS` | 可选。按 `entry_id` 复用已解析执行上下文的缓存秒数;到期、`lock`、`unbind` 或远端 `key_version` 变化后会失效。 | - -```bash -SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-local -# 启动后直接打开 http://127.0.0.1:9316/ -# 页面会引导你完成 bind -> approve -> unlock -> ready 全流程 +cargo fmt -- --check +cargo clippy --locked -- -D warnings +cargo test --locked ``` ## PostgreSQL TLS 加固 @@ -113,123 +53,57 @@ SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-loc - 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA),并保证证书 `SAN` 包含 `db.refining.ltd`。 - PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。 - 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`。 -- 可执行运维步骤见 [`deploy/postgres-tls-hardening.md`](deploy/postgres-tls-hardening.md)。 +- 可执行运维步骤见 `[deploy/postgres-tls-hardening.md](deploy/postgres-tls-hardening.md)`。 -## MCP 与 AI 工作流(v0.3+) +## MCP 与 AI 工作流(v3) -条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun` 与 `ricnsmart/aliyun`)。 +当前 v3 以 **桌面端 + 本地 daemon** 为主路径: -### 工具列表 +- 桌面端登录态仅在当前进程内有效,不持久化 `device token` +- 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp` +- daemon 通过活跃 desktop 进程提供的本地会话转发访问 API;desktop 进程退出后所有工具不可用 +- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_*` 环境变量 +- 不保留 `secrets_env_map` -| 工具 | 需要加密密钥 | 说明 | -|------|-------------|------| -| `secrets_find` | 否 | 发现条目(返回含 secret_fields schema),支持 `name_query` 模糊匹配 | -| `secrets_search` | 否 | 搜索条目,支持 `query`/`folder`/`type`/`name` 过滤、`sort`/`offset` 分页、`summary` 摘要模式 | -| `secrets_get` | 是 | 按 UUID `id` 获取单条条目及解密后的 secrets | -| `secrets_add` | 是 | 添加新条目,支持 `meta_obj`/`secrets_obj` JSON 对象参数、`secret_types` 指定密钥类型、`link_secret_names` 关联已有 secret | -| `secrets_update` | 是 | 更新条目,支持 `id` 或 `name`+`folder` 定位 | -| `secrets_delete` | 否 | 删除条目,支持 `id` 或 `name`+`folder` 定位;`dry_run=true` 预览删除 | -| `secrets_history` | 否 | 查看条目历史,支持 `id` 或 `name`+`folder` 定位 | -| `secrets_rollback` | 否 | 回滚条目到指定历史版本(服务端按历史快照恢复元数据与密文关联),支持 `id`;仅需 **Bearer**,不要求 `X-Encryption-Key` | -| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 | -| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射:`PREFIX_ENTRYNAME_FIELDNAME`(字段名中 `.`→`__`、`-`→`_` 再转大写,避免与纯下划线字段名碰撞),支持 `prefix` | -| `secrets_overview` | 否 | 返回各 folder 和 type 的 entry 计数概览 | +### Canonical MCP 工具 -### 消歧规则 +| 工具 | 说明 | +| --- | --- | +| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` | +| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 | +| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets | +| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata | +| `secrets_entry_delete` | 将本地对象标记为删除 | +| `secrets_entry_restore` | 恢复本地已删除对象 | +| `secrets_secret_add` | 向已有本地对象新增 secret | +| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 | +| `secrets_secret_delete` | 删除单个本地 secret | +| `secrets_secret_history` | 查看单个本地 secret 的历史版本 | +| `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 | +| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_*` 环境变量并执行本地命令 | -- **按 `name` 定位的工具**(`secrets_update` / `secrets_delete` / `secrets_history` / `secrets_rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`(UUID)跳过消歧。 -- **`secrets_get`** 仅支持通过 `id`(UUID)获取。 -- **`secrets_delete`** 的 `dry_run=true` 与真实删除使用相同消歧规则——唯一则预览一条,多条则报错并要求 `folder`。 +### 兼容别名 -### 共享密钥 +以下旧名称仍可用,但内部已转发到 v3 工具: -N:N 关联下,删除 entry 仅解除关联,被共享的 secret 若仍被其他 entry 引用则保留;无引用时自动清理。 - -## 加密架构(混合 E2EE) - -### 密钥派生 - -用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto API(PBKDF2-SHA256,600k 次迭代)**在本地派生 256-bit AES 密钥。 - -- **Salt(32B)**:首次设置时在浏览器生成,存入服务端 `users.key_salt` -- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语 -- **服务端不存储原始密钥**,只存 salt + key_check - -跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。 - -### 写入与读取流程 - -```mermaid -flowchart LR - subgraph Web["Web 浏览器(E2E)"] - P["密码短语"] --> K["PBKDF2 → 256-bit key"] - K --> Enc["AES-256-GCM 加密"] - K --> Dec["AES-256-GCM 解密"] - end - - subgraph AI["AI 客户端(MCP)"] - HdrKey["X-Encryption-Key: hex"] - end - - subgraph Server["secrets-mcp 服务端"] - Middleware["请求中临时持有 key\n请求结束即丢弃"] - DB[(PostgreSQL\nsecrets.encrypted = 密文\nentries.metadata = 明文)] - end - - Enc -->|密文| Server - HdrKey -->|key + 请求| Middleware - Middleware <-->|加解密| DB - DB -->|密文| Dec -``` - -### 两种客户端对比 - -| | Web 浏览器 | AI 客户端(MCP) | -|---|---|---| -| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 | -| 加解密位置 | 客户端(真正 E2E) | 服务端临时(请求级生命周期) | -| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 | - -### 敏感数据传输 - -- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器 -- **API Key** 当前存放在 `users.api_key`,Dashboard 会明文展示并可重置 -- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化) -- **生产环境必须走 HTTPS/TLS** +- `secrets_find` -> `secrets_entry_find` +- `secrets_add` -> `secrets_entry_add` +- `secrets_update` -> `secrets_entry_update` ## AI 客户端配置 -在 Web Dashboard 设置密码短语后,解锁页面会按客户端格式生成配置。常见客户端示例如下: +桌面端会自动把本地 daemon 写入以下配置: -`Cursor / Claude Desktop` 风格: +- `~/.cursor/mcp.json` +- `~/.claude/mcp.json` + +写入示例: ```json { "mcpServers": { "secrets": { - "url": "https://secrets.example.com/mcp", - "headers": { - "Authorization": "Bearer sk_abc123...", - "X-Encryption-Key": "a1b2c3...(64位hex)" - } - } - } -} -``` - -`OpenCode` 风格: - -```json -{ - "mcp": { - "secrets": { - "type": "remote", - "enabled": true, - "url": "https://secrets.example.com/mcp", - "headers": { - "Authorization": "Bearer sk_abc123...", - "X-Encryption-Key": "a1b2c3...(64位hex)" - } + "url": "http://127.0.0.1:9515/mcp" } } } @@ -237,77 +111,76 @@ flowchart LR ## 数据模型 -主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`name`、`type`、`encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**、**`local_mcp_bind_sessions`**(短时本地绑定确认会话)。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL,可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。 +当前 v3 已切到**零知识同步模型**: + +- 服务端保存 `vault_objects` 与 `vault_object_revisions` +- desktop 本地保存 `vault_objects`、`vault_object_history`、`pending_changes`、`sync_state` +- 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成 +- 服务端负责 `auth/device` 与 `/sync/*`,不再承担明文搜索与明文 reveal + +主要表: + +- `users` +- `oauth_accounts` +- `devices` +- `device_login_tokens` +- `auth_events` +- `vault_objects` +- `vault_object_revisions` + +字段职责: | 位置 | 字段 | 说明 | -|------|------|------| -| entries | folder | 组织/隔离空间,如 `refining`、`ricnsmart`;参与唯一键 | -| entries | type | 软分类,用户自定义,如 `server`、`service`、`account`、`person`、`document`(不参与唯一键) | -| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 | -| entries | notes | 非敏感说明文本 | -| entries | metadata | 明文 JSON(ip、url、subtype 等) | -| secrets | name | 密钥名称(调用方提供) | -| secrets | type | 密钥类型(调用方提供,默认 `text`) | -| secrets | encrypted | AES-GCM 密文(含 nonce) | -| users | key_salt | PBKDF2 salt(32B),首次设置密码短语时写入 | -| users | key_check | 派生密钥加密已知常量,用于验证密码短语 | -| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` | +| --- | --- | --- | +| `vault_objects` | `object_id` | 同步对象标识 | +| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` | +| `vault_objects` | `revision` | 服务端对象版本 | +| `vault_objects` | `ciphertext` | 密文对象载荷 | +| `vault_objects` | `content_hash` | 密文摘要 | +| `vault_objects` | `deleted_at` | 对象级删除标记 | +| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 | -### 共享密钥(N:N 关联) +## 认证与事件 -多个条目可共享同一密文字段,通过 `entry_secrets` 中间表实现 N:N 关联: -- 添加条目时可通过 `link_secret_names` 参数关联已有的 secret(按 `(user_id, name)` 精确匹配查找) -- 同一 secret 可被多个 entry 引用,删除某 entry 不会级联删除被共享的 secret -- 当 secret 不再被任何 entry 引用时,自动清理(`NOT EXISTS` 子查询) +当前登录流为 Google Desktop OAuth: -### 类型(Type) - -`type` 字段用于软分类,由用户自由填写,不做任何自动转换或归一化。常见示例:`server`、`service`、`account`、`person`、`document`,但任何值均可接受。 - -## 审计日志 - -`add`、`update`、`delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。多租户场景下可写 **`user_id`**(可空,兼容遗留行)。 -业务条目事件使用 **`folder` / `type` / `name`**;登录类事件使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标(例如 `oauth` / `google`),不表示某条 secrets entry。 - -```sql -SELECT action, folder, type, name, detail, user_id, created_at -FROM audit_log -ORDER BY created_at DESC -LIMIT 20; -``` +- 桌面端使用系统浏览器拉起 Google 授权 +- 使用本地 loopback callback + PKCE +- API 校验 Google userinfo 后发放 `device token` +- 登录与设备活动写入 `auth_events` ## 项目结构 -``` +```text Cargo.toml -crates/secrets-core/ # db / crypto / models / audit / service - src/ - taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项) - service/ # 业务逻辑(add, search, update, delete, export, env_map 等) -crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key;CHANGELOG.md 嵌入 /changelog -crates/secrets-mcp-local/ # 可选:本机 MCP gateway(bootstrap + ready 双工具面) -scripts/ - release-check.sh # 发版前 fmt / clippy / test - setup-gitea-actions.sh - sync-test-to-prod.sh # 测试库同步到生产(按需) +apps/ + api/ # 远端 JSON API + desktop/src-tauri/ # Tauri 桌面端 +crates/ + application/ # v3 应用服务 + client-integrations/ # Cursor / Claude Code mcp.json 注入 + crypto/ # 通用加密辅助 + desktop-daemon/ # 本地 MCP daemon + device-auth/ # Desktop OAuth / device token 辅助 + domain/ # 领域模型 + infrastructure-db/ # PostgreSQL 连接与迁移 deploy/ - .env.example # 环境变量模板 - secrets-mcp.service # systemd 服务文件(生产部署用) - postgres-tls-hardening.md # PostgreSQL TLS 加固运维手册 + .env.example + secrets-mcp.service + postgres-tls-hardening.md +scripts/ + release-check.sh + setup-gitea-actions.sh ``` ## CI/CD(Gitea Actions) -见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。 +当前以 workspace 级检查为主,见 `[.gitea/workflows/secrets.yml](.gitea/workflows/secrets.yml)`。 -- **触发**:任意分支 `push`,且变更路径包含 `crates/**`、`deploy/**`、根目录 `Cargo.toml` / `Cargo.lock`、`.gitea/workflows/**`。 -- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl` 的 `secrets-mcp` → 构建成功后打 tag `secrets-mcp-`(若远端已存在同名 tag,会先删除再于**当前提交**重建并推送,覆盖式发版)。 -- **Release(可选)**:配置仓库 Secret `RELEASE_TOKEN`(Gitea PAT,明文勿 base64)时,会通过 API **创建或更新**已指向该 tag 的 Release(非 draft)、上传 `tar.gz` 与 `.sha256`;未配置则跳过 API Release,仅 tag + 构建结果。 -- **部署(可选)**:仅在 `main`、`feat/mcp` 或 `mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER` 与 `secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`。 -- **通知(可选)**:`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。 +提交前建议直接运行: ```bash -./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等 +./scripts/release-check.sh ``` -详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。 +详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。 \ No newline at end of file diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml new file mode 100644 index 0000000..5286c28 --- /dev/null +++ b/apps/api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "secrets-api" +version = "0.1.0" +edition.workspace = true + +[[bin]] +name = "secrets-api" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +axum.workspace = true +dotenvy.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +chrono.workspace = true +reqwest.workspace = true + +secrets-application = { path = "../../crates/application" } +secrets-device-auth = { path = "../../crates/device-auth" } +secrets-domain = { path = "../../crates/domain" } +secrets-infrastructure-db = { path = "../../crates/infrastructure-db" } diff --git a/apps/api/src/bin/secrets-api-migrate.rs b/apps/api/src/bin/secrets-api-migrate.rs new file mode 100644 index 0000000..3ec6e8d --- /dev/null +++ b/apps/api/src/bin/secrets-api-migrate.rs @@ -0,0 +1,15 @@ +use anyhow::{Context, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let _ = dotenvy::dotenv(); + + let database_url = secrets_infrastructure_db::load_database_url()?; + let pool = secrets_infrastructure_db::create_pool(&database_url).await?; + secrets_infrastructure_db::migrate_current_schema(&pool) + .await + .context("failed to initialize current database schema")?; + + println!("current database schema initialized"); + Ok(()) +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs new file mode 100644 index 0000000..74fc382 --- /dev/null +++ b/apps/api/src/main.rs @@ -0,0 +1,568 @@ +use anyhow::{Context, Result as AnyResult}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + routing::{get, post}, +}; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use secrets_application::sync::{fetch_object, sync_pull, sync_push}; +use secrets_device_auth::{ + hash_device_login_token, new_device_fingerprint, new_device_login_token, +}; +use secrets_domain::{ + SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use tracing_subscriber::EnvFilter; +use uuid::Uuid; + +#[derive(Clone)] +struct AppState { + pool: PgPool, + http: Client, +} + +#[derive(Serialize)] +struct DemoLoginResponse { + device_token: String, +} + +#[derive(Debug, Deserialize)] +struct DesktopGoogleLoginRequest { + access_token: String, + device_name: String, + platform: String, + client_version: String, + device_fingerprint: String, +} + +#[derive(Debug, Deserialize)] +struct GoogleUserInfo { + email: String, + name: Option, +} + +#[derive(Serialize)] +struct DeviceView { + name: String, + platform: String, + client_version: String, + last_seen: String, + ip: Option, +} + +#[derive(Serialize)] +struct UserProfileView { + id: Uuid, + name: String, + email: String, +} + +#[derive(Serialize, sqlx::FromRow)] +struct UserRow { + id: Uuid, + email: Option, + name: String, +} + +#[derive(Serialize, sqlx::FromRow)] +struct DeviceRow { + id: Uuid, + display_name: String, + platform: String, + client_version: String, + last_seen_at: DateTime, + last_ip: Option, +} + +#[derive(Debug, Serialize)] +struct ObjectResponse { + object: VaultObjectEnvelope, +} + +#[tokio::main] +async fn main() -> AnyResult<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_api=info".into()), + ) + .init(); + + let database_url = secrets_infrastructure_db::load_database_url()?; + let pool = secrets_infrastructure_db::create_pool(&database_url).await?; + secrets_infrastructure_db::migrate_current_schema(&pool) + .await + .context("failed to initialize current database schema")?; + + let bind = std::env::var("SECRETS_API_BIND").unwrap_or_else(|_| "127.0.0.1:9415".to_string()); + let app = Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/auth/demo-login", post(api_demo_login)) + .route("/auth/google/desktop-login", post(api_google_desktop_login)) + .route("/me", get(api_me)) + .route("/sync/pull", post(api_sync_pull)) + .route("/sync/push", post(api_sync_push)) + .route("/sync/objects/{id}", get(api_sync_object)) + .route("/devices", get(api_devices)) + .with_state(AppState { + pool, + http: Client::new(), + }); + let listener = tokio::net::TcpListener::bind(&bind) + .await + .with_context(|| format!("failed to bind {}", bind))?; + + tracing::info!(bind = %bind, "secrets-api listening"); + axum::serve(listener, app) + .await + .context("api server error")?; + Ok(()) +} + +async fn api_demo_login( + State(state): State, +) -> std::result::Result, (StatusCode, Json)> { + let (user_id, device_id) = ensure_demo_user(&state.pool) + .await + .map_err(internal_error)?; + let device_token = new_device_login_token(); + let token_hash = hash_device_login_token(&device_token); + + sqlx::query("DELETE FROM device_login_tokens WHERE device_id = $1") + .bind(device_id) + .execute(&state.pool) + .await + .map_err(internal_error)?; + + sqlx::query( + r#" + INSERT INTO device_login_tokens (device_id, token_hash) + VALUES ($1, $2) + "#, + ) + .bind(device_id) + .bind(token_hash) + .execute(&state.pool) + .await + .map_err(internal_error)?; + + sqlx::query( + r#" + INSERT INTO auth_events ( + user_id, device_id, device_name, platform, client_version, ip_addr, forwarded_ip, login_method, login_result + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'device_token', 'success') + "#, + ) + .bind(user_id) + .bind(device_id) + .bind("Voson 的 Mac mini") + .bind("macOS") + .bind(env!("CARGO_PKG_VERSION")) + .bind::>(None) + .bind::>(None) + .execute(&state.pool) + .await + .map_err(internal_error)?; + + Ok(Json(DemoLoginResponse { device_token })) +} + +async fn api_google_desktop_login( + State(state): State, + Json(payload): Json, +) -> std::result::Result, (StatusCode, Json)> { + let google_user = state + .http + .get("https://openidconnect.googleapis.com/v1/userinfo") + .bearer_auth(&payload.access_token) + .send() + .await + .map_err(internal_error)? + .error_for_status() + .map_err(internal_error)? + .json::() + .await + .map_err(internal_error)?; + + let user_id = upsert_user_from_google(&state.pool, &google_user) + .await + .map_err(internal_error)?; + let device_id = upsert_device_for_login( + &state.pool, + user_id, + &payload.device_name, + &payload.platform, + &payload.client_version, + &payload.device_fingerprint, + ) + .await + .map_err(internal_error)?; + + let device_token = issue_device_login_token( + &state.pool, + user_id, + device_id, + &payload.device_name, + &payload.platform, + &payload.client_version, + ) + .await + .map_err(internal_error)?; + + Ok(Json(DemoLoginResponse { device_token })) +} + +async fn api_sync_pull( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> std::result::Result, (StatusCode, Json)> { + let (user, _) = require_auth(&state.pool, &headers).await?; + let response = sync_pull(&state.pool, user.id, payload) + .await + .map_err(internal_error)?; + Ok(Json(response)) +} + +async fn api_sync_push( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> std::result::Result, (StatusCode, Json)> { + let (user, _) = require_auth(&state.pool, &headers).await?; + let response = sync_push(&state.pool, user.id, payload) + .await + .map_err(internal_error)?; + Ok(Json(response)) +} + +async fn api_sync_object( + State(state): State, + headers: HeaderMap, + Path(object_id): Path, +) -> std::result::Result, (StatusCode, Json)> { + let (user, _) = require_auth(&state.pool, &headers).await?; + let object = fetch_object(&state.pool, user.id, object_id) + .await + .map_err(internal_error)? + .ok_or_else(|| unauthorized("object not found"))?; + Ok(Json(ObjectResponse { object })) +} + +async fn api_devices( + State(state): State, + headers: HeaderMap, +) -> std::result::Result>, (StatusCode, Json)> { + let (user, _) = require_auth(&state.pool, &headers).await?; + let rows = sqlx::query_as::<_, DeviceRow>( + r#" + SELECT + d.id, + d.display_name, + d.platform, + d.client_version, + d.last_seen_at, + COALESCE(NULLIF(a.forwarded_ip, ''), NULLIF(a.ip_addr, '')) AS last_ip + FROM devices d + LEFT JOIN LATERAL ( + SELECT ip_addr, forwarded_ip + FROM auth_events + WHERE device_id = d.id + ORDER BY created_at DESC + LIMIT 1 + ) a ON TRUE + WHERE d.user_id = $1 + ORDER BY last_seen_at DESC + "#, + ) + .bind(user.id) + .fetch_all(&state.pool) + .await + .map_err(internal_error)?; + + let devices = rows + .into_iter() + .map(|row| DeviceView { + name: row.display_name, + platform: row.platform, + client_version: row.client_version, + last_seen: row.last_seen_at.format("%Y-%m-%d %H:%M").to_string(), + ip: row.last_ip, + }) + .collect(); + + Ok(Json(devices)) +} + +async fn api_me( + State(state): State, + headers: HeaderMap, +) -> std::result::Result, (StatusCode, Json)> { + let (user, _) = require_auth(&state.pool, &headers).await?; + Ok(Json(UserProfileView { + id: user.id, + name: user.name, + email: user.email.unwrap_or_default(), + })) +} + +async fn require_auth( + pool: &PgPool, + headers: &HeaderMap, +) -> std::result::Result<(UserRow, DeviceRow), (StatusCode, Json)> { + let auth = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|raw| raw.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| unauthorized("missing bearer token"))?; + let token_hash = hash_device_login_token(auth); + + let row = sqlx::query_as::<_, DeviceRow>( + r#" + SELECT + d.id, + d.display_name, + d.platform, + d.client_version, + d.last_seen_at, + NULL::text AS last_ip + FROM device_login_tokens t + JOIN devices d ON d.id = t.device_id + WHERE t.token_hash = $1 + "#, + ) + .bind(&token_hash) + .fetch_optional(pool) + .await + .map_err(internal_error)? + .ok_or_else(|| unauthorized("invalid device token"))?; + + sqlx::query("UPDATE device_login_tokens SET last_seen_at = NOW() WHERE token_hash = $1") + .bind(&token_hash) + .execute(pool) + .await + .map_err(internal_error)?; + sqlx::query("UPDATE devices SET last_seen_at = NOW() WHERE id = $1") + .bind(row.id) + .execute(pool) + .await + .map_err(internal_error)?; + + let user = sqlx::query_as::<_, UserRow>( + r#" + SELECT u.id, u.email, u.name + FROM users u + JOIN devices d ON d.user_id = u.id + WHERE d.id = $1 + "#, + ) + .bind(row.id) + .fetch_one(pool) + .await + .map_err(internal_error)?; + + Ok((user, row)) +} + +async fn ensure_demo_user(pool: &PgPool) -> AnyResult<(Uuid, Uuid)> { + let existing = + sqlx::query_as::<_, UserRow>("SELECT id, email, name FROM users WHERE email = $1 LIMIT 1") + .bind("voson.wang.s@gmail.com") + .fetch_optional(pool) + .await?; + + let user_id = if let Some(user) = existing { + user.id + } else { + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO users (email, name) + VALUES ($1, $2) + RETURNING id + "#, + ) + .bind("voson.wang.s@gmail.com") + .bind("Voson") + .fetch_one(pool) + .await? + }; + + let existing_device = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM devices WHERE user_id = $1 AND display_name = $2 LIMIT 1", + ) + .bind(user_id) + .bind("Voson 的 Mac mini") + .fetch_optional(pool) + .await?; + + let device_id = if let Some(id) = existing_device { + id + } else { + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO devices (user_id, display_name, platform, client_version, device_fingerprint) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + "#, + ) + .bind(user_id) + .bind("Voson 的 Mac mini") + .bind("macOS") + .bind(env!("CARGO_PKG_VERSION")) + .bind(new_device_fingerprint()) + .fetch_one(pool) + .await? + }; + + Ok((user_id, device_id)) +} + +async fn upsert_user_from_google(pool: &PgPool, google_user: &GoogleUserInfo) -> AnyResult { + let existing = sqlx::query_scalar::<_, Uuid>("SELECT id FROM users WHERE email = $1 LIMIT 1") + .bind(&google_user.email) + .fetch_optional(pool) + .await?; + + if let Some(user_id) = existing { + sqlx::query("UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2") + .bind( + google_user + .name + .clone() + .unwrap_or_else(|| google_user.email.clone()), + ) + .bind(user_id) + .execute(pool) + .await?; + return Ok(user_id); + } + + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO users (email, name) + VALUES ($1, $2) + RETURNING id + "#, + ) + .bind(&google_user.email) + .bind( + google_user + .name + .clone() + .unwrap_or_else(|| google_user.email.clone()), + ) + .fetch_one(pool) + .await + .context("failed to create user from google login") +} + +async fn upsert_device_for_login( + pool: &PgPool, + user_id: Uuid, + device_name: &str, + platform: &str, + client_version: &str, + device_fingerprint: &str, +) -> AnyResult { + let existing = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM devices WHERE user_id = $1 AND device_fingerprint = $2 LIMIT 1", + ) + .bind(user_id) + .bind(device_fingerprint) + .fetch_optional(pool) + .await?; + + if let Some(device_id) = existing { + sqlx::query( + r#" + UPDATE devices + SET display_name = $1, platform = $2, client_version = $3, last_seen_at = NOW() + WHERE id = $4 + "#, + ) + .bind(device_name) + .bind(platform) + .bind(client_version) + .bind(device_id) + .execute(pool) + .await?; + return Ok(device_id); + } + + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO devices (user_id, display_name, platform, client_version, device_fingerprint) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + "#, + ) + .bind(user_id) + .bind(device_name) + .bind(platform) + .bind(client_version) + .bind(device_fingerprint) + .fetch_one(pool) + .await + .context("failed to create device") +} + +async fn issue_device_login_token( + pool: &PgPool, + user_id: Uuid, + device_id: Uuid, + device_name: &str, + platform: &str, + client_version: &str, +) -> AnyResult { + let device_token = new_device_login_token(); + let token_hash = hash_device_login_token(&device_token); + + sqlx::query("DELETE FROM device_login_tokens WHERE device_id = $1") + .bind(device_id) + .execute(pool) + .await?; + sqlx::query("INSERT INTO device_login_tokens (device_id, token_hash) VALUES ($1, $2)") + .bind(device_id) + .bind(token_hash) + .execute(pool) + .await?; + sqlx::query( + r#" + INSERT INTO auth_events ( + user_id, device_id, device_name, platform, client_version, ip_addr, forwarded_ip, login_method, login_result + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'google_desktop', 'success') + "#, + ) + .bind(user_id) + .bind(device_id) + .bind(device_name) + .bind(platform) + .bind(client_version) + .bind::>(None) + .bind::>(None) + .execute(pool) + .await?; + + Ok(device_token) +} + +fn internal_error(error: E) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": error.to_string() })), + ) +} + +fn unauthorized(message: &str) -> (StatusCode, Json) { + (StatusCode::UNAUTHORIZED, Json(json!({ "error": message }))) +} diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..fd7c271 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,6 @@ +# apps/desktop + +This directory is reserved for the v3 Tauri desktop shell. + +The desktop UI is intentionally kept separate from `crates/desktop-daemon` so +that closing the main window does not terminate the local MCP process. diff --git a/apps/desktop/design/DESIGN.md b/apps/desktop/design/DESIGN.md new file mode 100644 index 0000000..3457357 --- /dev/null +++ b/apps/desktop/design/DESIGN.md @@ -0,0 +1,208 @@ +# Secrets Design System + +## 1. Visual Theme & Atmosphere + +- Primary inspiration: Raycast desktop UI. +- Secondary influence: Linear information density and list discipline. +- Product personality: secure, local-first, developer-facing, restrained, trustworthy. +- Default mood: dark utility app, not a marketing site and not a glossy consumer app. +- The interface should feel like a native desktop control surface for secrets and MCP integrations. +- Use calm contrast, clean edges, compact spacing, and intentional empty space. +- Prefer precision over decoration. Visual polish should come from alignment, spacing, and hierarchy. + +## 2. Color Palette & Roles + +### Core Surfaces + +- `bg.app`: `#0A0A0B` - app background, deepest canvas. +- `bg.panel`: `#111113` - main panel and modal background. +- `bg.panelElevated`: `#17171A` - cards, selected rows, input shells. +- `bg.panelHover`: `#1D1D22` - hover state for rows and controls. +- `bg.input`: `#141418` - text inputs, code blocks, secret fields. +- `border.subtle`: `#26262C` - default panel borders. +- `border.strong`: `#34343D` - active borders and high-emphasis outlines. + +### Text + +- `text.primary`: `#F5F5F7` - primary labels and values. +- `text.secondary`: `#B3B3BD` - supporting metadata. +- `text.tertiary`: `#7C7C88` - placeholders and low-emphasis copy. +- `text.inverse`: `#0B0B0D` - text on bright accents. + +### Accents + +- `accent.blue`: `#3B82F6` - login CTA, toggles, focus ring, trust signals. +- `accent.blueHover`: `#4C8DFF` - hover state for primary interactions. +- `accent.purple`: `#8B5CF6` - secondary accent for selected count pills or light emphasis. +- `accent.amber`: `#D97706` - local warnings or pending states. +- `accent.red`: `#EF4444` - destructive actions. +- `accent.green`: `#22C55E` - success or enabled state when stronger signal is required. + +### Semantic Use + +- Blue is the main action color. Keep it rare and meaningful. +- Purple can appear in subtle badges or selected-count chips, never as a second primary CTA. +- Red is reserved for delete, revoke, sign-out danger, and destructive confirmations. +- Avoid bright gradients as a dominant surface treatment. + +## 3. Typography Rules + +- Font stack: `Inter`, `SF Pro Text`, `SF Pro Display`, `Segoe UI`, system sans-serif. +- Use system-friendly text rendering. This is a desktop tool, not a display-heavy website. +- Chinese UI copy is allowed and should feel natural beside English identifiers like `host`, `token`, `MCP`. +- Keep tracking neutral. Avoid wide uppercase spacing except tiny overline labels. + +### Type Scale + +- App title / page title: 30-34px, weight 700. +- Section title: 18-22px, weight 650-700. +- Card title / row title: 15-17px, weight 600. +- Body text: 13-14px, weight 400-500. +- Caption / metadata label: 11-12px, weight 500, uppercase allowed with modest tracking. +- Monospace values: `SF Mono`, `JetBrains Mono`, `Menlo`, monospace; 12-13px. + +## 4. Component Stylings + +### App Shell + +- Use a three-pane desktop layout for the main screen: left navigation, middle list, right detail pane. +- Pane separation should rely on subtle borders, not strong shadows. +- Sidebar should feel slightly darker than the center list pane. +- The detail pane can be the most open surface, with larger top padding and calmer spacing. + +### Login Card + +- Centered card on a dark canvas. +- Width: compact, roughly 420-520px. +- Rounded corners: 24-28px. +- Include one lock/trust mark, one clear product title, one short support sentence, one primary Google login button. +- Login should feel calm and premium, never busy. + +### Buttons + +- Primary button: dark app shell with blue fill, white text, medium radius. +- Secondary button: dark raised surface with subtle border. +- Destructive button: same structure as secondary, with red text or red-emphasis border only when needed. +- Button height should feel desktop-like, not mobile oversized. +- Avoid flashy gradients and oversized glows. + +### Inputs + +- Inputs use dark filled surfaces, subtle inset feel, 12-14px radius. +- Border should be nearly invisible at rest and stronger on hover/focus. +- Placeholders should be quiet and low-contrast. +- Search and filter inputs should visually align and share the same height. + +### Lists and Rows + +- Entry rows should be compact, crisp, and easy to scan. +- Selected row: slightly brighter dark card, subtle border, no heavy glow. +- Support a two-line rhythm: primary name and smaller type/folder metadata. +- Counts in the sidebar should use muted rounded chips. + +### Detail Pane + +- Use strong top title hierarchy with restrained action buttons on the right. +- Metadata should be presented in structured blocks or columns, not loose paragraphs. +- Secret values should live inside dedicated protected field cards. +- Secret field rows should include icon, masked value, reveal action, and copy action. +- Sensitive content must look controlled and deliberate, not playful. + +### Modals + +- Modal cards should feel like elevated control panels. +- MCP integration modal should support stacked integration rows with trailing toggles. +- Embedded JSON/config blocks should use a darker, code-oriented surface with monospace text. +- Large modal width is acceptable for configuration-heavy content. + +### Toggles + +- Use blue enabled state by default. +- Toggle track should be compact and clean, avoiding iOS-like softness. +- Align toggles flush right in integration lists. + +### Badges and Status Pills + +- Use small rounded pills for folder counts, archived state, or recent-delete state. +- Prefer muted purple, gray, or amber fills over saturated color blocks. + +## 5. Layout Principles + +- Use an 8px spacing system. +- Typical paddings: +- Sidebars: 16-20px. +- List and toolbar: 12-18px. +- Detail pane: 24-32px. +- Modals: 20-28px. +- Favor even vertical rhythm over decorative separators. +- Keep left edges aligned aggressively across sections. +- Avoid oversized hero spacing inside application surfaces. +- The main app should feel dense enough for productivity but never cramped. + +## 6. Depth & Elevation + +- Most separation should come from tone shifts and borders. +- Base panels: no shadow or extremely soft shadow. +- Elevated cards and modals: subtle shadow only, with low blur and low opacity. +- Do not use neon bloom, oversized backdrop blur, or glassmorphism. +- Focus states should use border color and a faint blue outer ring. + +## 7. Do's and Don'ts + +### Do + +- Keep the UI dark, crisp, and desktop-native. +- Preserve strong information hierarchy in the detail pane. +- Make security-sensitive actions feel explicit and carefully gated. +- Use compact controls and disciplined spacing. +- Let alignment and typography carry most of the visual quality. +- Keep MCP integration screens structured like settings panels. + +### Don't + +- Do not turn the app into a landing page aesthetic. +- Do not use giant gradients, colorful illustrations, or soft SaaS cards. +- Do not over-round every surface. +- Do not mix many accent colors in one screen. +- Do not make secret fields look like casual form inputs. +- Do not use bright white backgrounds in the desktop app. + +## 8. Responsive Behavior + +- Primary target is desktop widths from 1280px upward. +- The three-pane shell should remain stable on desktop. +- At narrower widths, collapse from three panes to two panes before using stacked mobile behavior. +- The MCP modal can reduce width but should keep readable row spacing and code block legibility. +- Buttons and toggles should remain mouse-first, with minimum 32px touch-friendly height where practical. + +## 9. Screen-Specific Guidance + +### Login Screen + +- Centered trust card. +- One focal icon or emblem above the title. +- Keep copy short. +- The Google login button should be the visual anchor. + +### Main Secrets Screen + +- Left sidebar: user card, folder navigation, utility actions near the bottom. +- Middle pane: search, type filter, result list. +- Right pane: selected entry title, metadata grid, secret cards, edit actions. +- The selected item should be immediately obvious but understated. + +### MCP Integration Screen + +- Treat as a settings modal. +- Integration rows should read like desktop preferences, not marketing feature cards. +- JSON config block should feel developer-native and copy-friendly. + +## 10. Agent Prompt Guide + +- Keywords: `dark desktop utility`, `Raycast-inspired`, `Linear-density`, `secure control panel`, `developer tool`, `restrained premium`, `MCP settings modal`. +- When generating screens, preserve: dark surfaces, subtle borders, compact controls, right-aligned actions, clean typography, muted status pills. +- If unsure, bias toward less decoration and tighter structure. + +## 11. Quick Summary for Agents + +Build Secrets like a polished desktop utility: mostly Raycast in atmosphere, a little Linear in density, with dark layered panels, precise typography, subtle borders, blue-only primary actions, and security-sensitive detail cards that feel calm, serious, and highly usable. \ No newline at end of file diff --git a/apps/desktop/design/secrets-client.pen b/apps/desktop/design/secrets-client.pen new file mode 100644 index 0000000..8bf53a5 --- /dev/null +++ b/apps/desktop/design/secrets-client.pen @@ -0,0 +1,6300 @@ +{ + "version": "2.10", + "children": [ + { + "type": "frame", + "id": "VUST9", + "x": 0, + "y": 0, + "name": "Secrets 桌面客户端", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#0A0A0B", + "cornerRadius": 24, + "children": [ + { + "type": "frame", + "id": "HglS7", + "name": "sidebar", + "width": 248, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 20, + 16 + ], + "children": [ + { + "type": "frame", + "id": "81HCT", + "name": "userRow", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "YKSJP", + "name": "avatar", + "width": 36, + "height": 36, + "fill": "#D97706", + "cornerRadius": 999, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GIm0e", + "name": "avatarTxt", + "fill": "#f4f4f5", + "content": "V", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "VWAHj", + "name": "userCopy", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "qB6GT", + "name": "nameTxt", + "fill": "#f4f4f5", + "content": "用户姓名", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "8MVra", + "name": "emailTxt", + "fill": "#a1a1aa", + "content": "user@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "WSTa7", + "name": "folderStack", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "8iFyZ", + "name": "allItems", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WWmGV", + "name": "allT", + "fill": "#9ca3af", + "content": "所有项目", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "jAH4E", + "name": "allCountBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lMf6Y", + "name": "allC", + "fill": "#9ca3af", + "content": "17", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wazyD", + "name": "f1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "yw9Y2", + "name": "f1t", + "fill": "#a5b4fc", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "UKnpL", + "name": "f1countBadge", + "fill": "#2A2440", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qWOcB", + "name": "f1c", + "fill": "#a5b4fc", + "content": "12", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wv7N6", + "name": "f2", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Njtaj", + "name": "f2t", + "fill": "#9ca3af", + "content": "personal", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "frame", + "id": "9YLTS", + "name": "f2countBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jhFCN", + "name": "f2c", + "fill": "#9ca3af", + "content": "5", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rVEw4", + "name": "folderTrashGap", + "width": "fill_container", + "height": 32 + }, + { + "type": "frame", + "id": "S1di3", + "name": "trashFolder", + "width": "fill_container", + "fill": "#0F0F12", + "cornerRadius": 12, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QU0gb", + "name": "trashT", + "fill": "#71717a", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "miwfN", + "name": "trashC", + "fill": "#71717a", + "content": "2", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wCFqj", + "name": "sidebarSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "FLtyL", + "name": "mcpEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "e8CSg", + "name": "mcpIcon", + "width": 18, + "height": 18, + "iconFontName": "plug", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "JfAck", + "name": "mcpTxt", + "fill": "#9ca3af", + "content": "MCP", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "uCLgF", + "name": "logoutEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "L4Dl4", + "name": "logoutIcon", + "width": 18, + "height": 18, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "E8ZPG", + "name": "logoutTxt", + "fill": "#9ca3af", + "content": "退出登录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Qd8M8", + "name": "mainArea", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "2w0hJ", + "name": "contentRow", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "children": [ + { + "type": "frame", + "id": "awAbR", + "name": "listColumn", + "width": 404, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "4ebVE", + "name": "searchShell", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 18, + 18, + 14, + 18 + ], + "children": [ + { + "type": "frame", + "id": "zU2o2", + "name": "searchInput", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "children": [ + { + "type": "text", + "id": "lPEM0", + "name": "searchPlace", + "fill": "#71717a", + "content": "按 名称 模糊搜索 ", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "3ArXN", + "name": "listPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#3f3f46" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "KQO6t", + "name": "toolbar", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 18 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "k5E0l", + "name": "filterMock", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 10, + 12 + ], + "children": [ + { + "type": "text", + "id": "6r1wB", + "name": "filterTxt", + "fill": "#f4f4f5", + "content": "全部类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "0cTpA", + "name": "listBody", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 14, + 18, + 18, + 18 + ], + "children": [ + { + "type": "frame", + "id": "JIQdQ", + "name": "e1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "XrfNa", + "name": "e1t", + "fill": "#f4f4f5", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "XbtEn", + "name": "e1s", + "fill": "#a1a1aa", + "content": "service", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "thYte", + "name": "e2", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "SfPhJ", + "name": "e2t", + "fill": "#f4f4f5", + "content": "Gmail 工作邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "N3q2g", + "name": "e2s", + "fill": "#a1a1aa", + "content": "account", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "BDNfn", + "name": "listDetailDivider", + "fill": "#26262C", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "xmlSY", + "name": "detailPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#3f3f46" + }, + "layout": "vertical", + "gap": 32, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "9ojMf", + "name": "dh", + "width": "fill_container", + "gap": 20, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Qd57g", + "name": "detailLeft", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "qt8u8", + "name": "folderLabel", + "fill": "#7C7C88", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "JGTpL", + "name": "dtitle", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oDpCN", + "name": "h2", + "fill": "#F5F5F7", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "7phTL", + "x": 91, + "y": 7, + "name": "badge", + "enabled": false, + "fill": "#2A2440", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gBjRC", + "name": "badgeTxt", + "fill": "#C4B5FD", + "content": "service", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "1Vju1", + "name": "dactions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ANJsH", + "name": "btnEdit", + "fill": "#1A1A1D", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "gap": 8, + "padding": [ + 10, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "I0PwC", + "name": "editIcon", + "width": 14, + "height": 14, + "iconFontName": "pencil", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + }, + { + "type": "text", + "id": "Krnqy", + "name": "btnEditTxt", + "fill": "#D4D4D8", + "content": "编辑", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "XOoXN", + "name": "btnDel", + "fill": "#2B1316", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#4B2529" + }, + "gap": 8, + "padding": [ + 10, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jIwOM", + "name": "deleteIcon", + "width": 14, + "height": 14, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "#F87171" + }, + { + "type": "text", + "id": "p4Vs6", + "name": "btnDelTxt", + "fill": "#F87171", + "content": "删除", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "fhuoj", + "name": "secMeta", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "frame", + "id": "G7YIV", + "name": "metaHdr", + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "fsUxZ", + "name": "metaTitle", + "fill": "#7C7C88", + "content": "元数据", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + } + ] + }, + { + "type": "rectangle", + "id": "BSXcE", + "x": 0, + "y": 40, + "name": "metaDivider", + "enabled": false, + "fill": "#2a2a2e", + "width": "fill_container(731)", + "height": 1 + }, + { + "type": "frame", + "id": "tIPLK", + "name": "metaList", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "BWIOy", + "name": "row1", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "93Hfb", + "name": "t1", + "fill": "#7C7C88", + "textGrowth": "fixed-width", + "width": 100, + "content": "HOST", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "Pur38", + "name": "valFrame1", + "width": "fill_container", + "gap": 12, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "oXuMX", + "name": "v1", + "fill": "#F5F5F7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "git.example.com", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "E09tp", + "name": "c1", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + }, + { + "type": "frame", + "id": "HBX4c", + "name": "row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "XzMRO", + "name": "t2", + "fill": "#7C7C88", + "textGrowth": "fixed-width", + "width": 100, + "content": "USERNAME", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "5vgvy", + "name": "valFrame2", + "width": "fill_container", + "gap": 12, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "vdsD7", + "name": "v2", + "fill": "#F5F5F7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "developer_admin", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "DSRGN", + "name": "c2", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + }, + { + "type": "frame", + "id": "0dtYy", + "name": "row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "r1pAn", + "name": "t3", + "fill": "#7C7C88", + "textGrowth": "fixed-width", + "width": 100, + "content": "创建时间", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "kmIrk", + "name": "valFrame3", + "width": "fill_container", + "gap": 12, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "qNy16", + "name": "v3", + "fill": "#F5F5F7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2023-11-24 14:30:05 UTC", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "F4UGB", + "name": "c3", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "HOMeN", + "name": "secSec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "frame", + "id": "lrsMv", + "name": "secHdr", + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "xtoDu", + "name": "shTxt", + "fill": "#7C7C88", + "content": "密钥", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + } + ] + }, + { + "type": "frame", + "id": "asB1h", + "name": "secretCard", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 18, + "padding": 22, + "children": [ + { + "type": "frame", + "id": "NlzlD", + "name": "secretTitleCol", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "PQzPG", + "name": "nameRow", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "r73g7", + "name": "nVal", + "fill": "#7C7C88", + "content": "访问令牌", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.08 + } + ] + } + ] + }, + { + "type": "frame", + "id": "FDdV0", + "name": "secretValueRow", + "width": "fill_container", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cIhIB", + "name": "secretValueShell", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "J8ard", + "name": "leftPk", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "SoIK1", + "name": "lockIco", + "width": 16, + "height": 16, + "iconFontName": "lock", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + }, + { + "type": "text", + "id": "mkxo5", + "name": "maskTxt", + "fill": "#B3B3BD", + "content": "••••••••••••", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "IpEws", + "name": "secretSpacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon_font", + "id": "x3g7L", + "name": "eyeIc", + "width": 16, + "height": 16, + "iconFontName": "eye-off", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Ba30z", + "name": "copyBtn", + "fill": "#17171A", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QvFOu", + "name": "cpIcon", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + }, + { + "type": "text", + "id": "2mdm0", + "name": "cpLbl", + "fill": "#F5F5F7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "niVgM", + "x": 1480, + "y": 0, + "name": "Secrets 登录", + "clip": true, + "width": 420, + "height": 340, + "fill": "#111113", + "cornerRadius": 24, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000066", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 24 + }, + "layout": "vertical", + "padding": [ + 48, + 40, + 34, + 40 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "JI8jn", + "name": "loginMain", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "WhuiW", + "name": "lockBadge", + "width": 56, + "height": 56, + "fill": "#141418", + "cornerRadius": 999, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "PwfrH", + "name": "lockIcon", + "width": 24, + "height": 24, + "iconFontName": "lock-keyhole", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + } + ] + }, + { + "type": "frame", + "id": "PCw0B", + "name": "titleBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ATV2M", + "name": "SECRETS 标题", + "fill": "#F5F5F7", + "content": "Secrets", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + }, + { + "type": "text", + "id": "BffHv", + "name": "subtitle", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "用 AI 安全的管理和使用密钥", + "lineHeight": 1.45, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "cMAB9", + "name": "Google 登录", + "width": "fill_container", + "height": 40, + "fill": "#3B82F6", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#3B82F6" + }, + "gap": 10, + "padding": [ + 10, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ze6AY", + "name": "whiteG", + "width": 18, + "height": 18, + "fill": "#3B82F6", + "layout": "none", + "children": [ + { + "type": "path", + "id": "gBfab", + "x": 0, + "y": 0, + "geometry": "M22.56 12.25c0-0.78-0.07-1.53-0.2-2.25h-10.36v4.26h5.92c-0.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z m-10.56 10.75c2.97 0 5.46-0.98 7.28-2.66l-3.57-2.77c-0.98 0.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53h-3.66v2.84c1.81 3.59 5.52 6.06 9.82 6.06z m-6.16-8.91c-0.22-0.66-0.35-1.36-0.35-2.09s0.13-1.43 0.35-2.09v-2.84h-3.66c-0.75 1.48-1.18 3.15-1.18 4.93s0.43 3.45 1.18 4.93l2.85-2.22 0.81-0.62z m6.16-8.71c1.62 0 3.06 0.56 4.21 1.64l3.15-3.15c-1.91-1.78-4.39-2.87-7.36-2.87-4.3 0-8.01 2.47-9.82 6.07l3.66 2.84c0.87-2.6 3.3-4.53 6.16-4.53z", + "fill": "#F5F5F7", + "width": 18, + "height": 18 + } + ] + }, + { + "type": "text", + "id": "qSKOe", + "name": "btnTxt", + "fill": "#F5F5F7", + "content": "使用 Google 登录", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DMrE7", + "x": 2027, + "y": 0, + "name": "MCP 设置弹窗", + "clip": true, + "width": 960, + "height": 803, + "fill": "#111113", + "cornerRadius": 24, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000044", + "offset": { + "x": 0, + "y": 12 + }, + "blur": 24 + }, + "children": [ + { + "type": "frame", + "id": "qoWoX", + "name": "mcpMain", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "layout": "vertical", + "gap": 28, + "padding": 28, + "children": [ + { + "type": "frame", + "id": "cj5jC", + "name": "mcpTerminalSec", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 18, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 18, + "padding": [ + 20, + 20, + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "PRGq0", + "name": "row1", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "1pXzP", + "name": "t1", + "fill": "#F5F5F7", + "content": "MCP 集成", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "lbZdR", + "name": "tgA", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "5v1Cy", + "name": "tgLeftA", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "VaNjr", + "name": "iconA", + "width": 34, + "height": 34, + "fill": "#17171A", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QPiwk", + "name": "ia", + "width": 16, + "height": 16, + "iconFontName": "terminal", + "iconFontFamily": "lucide", + "fill": "#a1a1aa" + } + ] + }, + { + "type": "text", + "id": "TPOvr", + "name": "tgAt", + "fill": "#e4e4e7", + "content": "Claude Code CLI", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zIOq5", + "name": "tgAo", + "width": 40, + "height": 22, + "fill": "#3B82F6", + "cornerRadius": 999, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#4C8DFF" + }, + "padding": 3, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "nMpgi", + "name": "tgAKnob", + "fill": "#111113", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#0A0A0B" + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "qJtOT", + "name": "tgB", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CyOlb", + "name": "tgLeftB", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "M0ssu", + "name": "iconB", + "width": 34, + "height": 34, + "fill": "#17171A", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "sQgij", + "name": "ib", + "width": 16, + "height": 16, + "iconFontName": "code", + "iconFontFamily": "lucide", + "fill": "#a1a1aa" + } + ] + }, + { + "type": "text", + "id": "V4jto", + "name": "tgBt", + "fill": "#e4e4e7", + "content": "Codex CLI", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ALmDR", + "name": "tgBo", + "width": 40, + "height": 22, + "fill": "#3B82F6", + "cornerRadius": 999, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#4C8DFF" + }, + "padding": 3, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "orF41", + "name": "tgBKnob", + "fill": "#111113", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#0A0A0B" + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "bK7o5", + "name": "tgC", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Fil14", + "name": "tgLeftC", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "5ukah", + "name": "iconC", + "width": 34, + "height": 34, + "fill": "#17171A", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "qax7a", + "name": "ic", + "width": 16, + "height": 16, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#a1a1aa" + } + ] + }, + { + "type": "text", + "id": "qSYmP", + "name": "tgCt", + "fill": "#e4e4e7", + "content": "Gemini CLI", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "22adv", + "name": "tgCo", + "width": 40, + "height": 22, + "fill": "#3B82F6", + "cornerRadius": 999, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#4C8DFF" + }, + "padding": 3, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "3wBIN", + "name": "tgCKnob", + "fill": "#111113", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#0A0A0B" + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "XMfqF", + "name": "tgD", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hhZzy", + "name": "tgLeftD", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MSL9a", + "name": "iconD", + "width": 34, + "height": 34, + "fill": "#17171A", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "pRgrC", + "name": "id", + "width": 16, + "height": 16, + "iconFontName": "bot", + "iconFontFamily": "lucide", + "fill": "#a1a1aa" + } + ] + }, + { + "type": "text", + "id": "vMFXz", + "name": "tgDt", + "fill": "#e4e4e7", + "content": "OpenCode CLI", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "3ADTE", + "name": "tgDo", + "width": 40, + "height": 22, + "fill": "#3B82F6", + "cornerRadius": 999, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#4C8DFF" + }, + "padding": 3, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "nOB0N", + "name": "tgDKnob", + "fill": "#111113", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#0A0A0B" + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "SlOJI", + "name": "tgE", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "padding": [ + 14, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "aUK4n", + "name": "tgLeftE", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BGdYj", + "name": "iconE", + "width": 34, + "height": 34, + "fill": "#17171A", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "OJnPV", + "name": "ie", + "width": 16, + "height": 16, + "iconFontName": "monitor", + "iconFontFamily": "lucide", + "fill": "#a1a1aa" + } + ] + }, + { + "type": "text", + "id": "fUSHN", + "name": "tgEt", + "fill": "#e4e4e7", + "content": "Cursor Desktop", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HzrZT", + "name": "tgEo", + "width": 40, + "height": 22, + "fill": "#3B82F6", + "cornerRadius": 999, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#4C8DFF" + }, + "padding": 3, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "L0uyV", + "name": "tgEKnob", + "fill": "#111113", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#0A0A0B" + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zUewk", + "name": "mcpCustomSec", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 18, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 16, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "jpOmL", + "name": "hdr2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "phtIZ", + "name": "h2l", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QnwdQ", + "name": "h2t", + "fill": "#F5F5F7", + "content": "自定义 MCP 服务配置", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "650" + } + ] + }, + { + "type": "frame", + "id": "kivhq", + "name": "copyBtn", + "fill": "#17171A", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EN1nn", + "name": "copyIcon2", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + }, + { + "type": "text", + "id": "HgsI2", + "name": "copyText2", + "fill": "#F5F5F7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "WmKYp", + "name": "jsonBox", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "padding": 18, + "children": [ + { + "type": "text", + "id": "sDZMn", + "name": "mcpJsonSample", + "fill": "#CBD5E1", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"mcpServers\": {\n \"secrets\": {\n \"url\": \"http://127.0.0.1:9515/mcp\"\n }\n }\n}", + "lineHeight": 1.5, + "fontFamily": "JetBrains Mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ms1oT", + "x": 3107, + "y": 0, + "name": "Secrets 主页面 - 未选中态", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#0A0A0B", + "cornerRadius": 24, + "children": [ + { + "type": "frame", + "id": "PW3SZ", + "name": "sidebar", + "width": 248, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 20, + 16 + ], + "children": [ + { + "type": "frame", + "id": "ZDkaI", + "name": "userRow", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BSjsb", + "name": "avatar", + "width": 36, + "height": 36, + "fill": "#D97706", + "cornerRadius": 999, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tGRfs", + "name": "avatarTxt", + "fill": "#f4f4f5", + "content": "V", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "GtDs2", + "name": "userCopy", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "57RxP", + "name": "nameTxt", + "fill": "#f4f4f5", + "content": "用户姓名", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Idn2c", + "name": "emailTxt", + "fill": "#a1a1aa", + "content": "user@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "fy3Jq", + "name": "folderStack", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "cvJ1t", + "name": "allItems", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YmNcF", + "name": "allT", + "fill": "#9ca3af", + "content": "所有项目", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "RnQsJ", + "name": "allCountBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MSZsw", + "name": "allC", + "fill": "#9ca3af", + "content": "17", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "FKvzX", + "name": "f1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F8YhP", + "name": "f1t", + "fill": "#a5b4fc", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "bMQCE", + "name": "f1countBadge", + "fill": "#2A2440", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ISEnl", + "name": "f1c", + "fill": "#a5b4fc", + "content": "12", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "0NKfp", + "name": "f2", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OnSxM", + "name": "f2t", + "fill": "#9ca3af", + "content": "personal", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "frame", + "id": "FS55i", + "name": "f2countBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "6ioar", + "name": "f2c", + "fill": "#9ca3af", + "content": "5", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "5JV4g", + "name": "folderTrashGap", + "width": "fill_container", + "height": 32 + }, + { + "type": "frame", + "id": "wyxFD", + "name": "trashFolder", + "width": "fill_container", + "fill": "#0F0F12", + "cornerRadius": 12, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vIqE4", + "name": "trashT", + "fill": "#71717a", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "2NhqW", + "name": "trashC", + "fill": "#71717a", + "content": "2", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "H3GCl", + "name": "sidebarSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "5GY3K", + "name": "mcpEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "DEeEA", + "name": "mcpIcon", + "width": 18, + "height": 18, + "iconFontName": "plug", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "wWcGN", + "name": "mcpTxt", + "fill": "#9ca3af", + "content": "MCP", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "tAZKF", + "name": "logoutEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "9EORC", + "name": "logoutIcon", + "width": 18, + "height": 18, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "oudiQ", + "name": "logoutTxt", + "fill": "#9ca3af", + "content": "退出登录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nwd4E", + "name": "mainArea", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "PEMQI", + "name": "contentRow", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "children": [ + { + "type": "frame", + "id": "FF81c", + "name": "listColumn", + "width": 404, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "5KU8V", + "name": "searchShell", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 18, + 18, + 14, + 18 + ], + "children": [ + { + "type": "frame", + "id": "bgCHf", + "name": "searchInput", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "children": [ + { + "type": "text", + "id": "wO98I", + "name": "searchPlace", + "fill": "#71717a", + "content": "按 名称 模糊搜索 ", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "LyVlV", + "name": "listPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#3f3f46" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "QhqMm", + "name": "toolbar", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 18 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RbPvA", + "name": "filterMock", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 10, + 12 + ], + "children": [ + { + "type": "text", + "id": "hob4N", + "name": "filterTxt", + "fill": "#f4f4f5", + "content": "全部类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lBlc7", + "name": "listBody", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 14, + 18, + 18, + 18 + ], + "children": [ + { + "type": "frame", + "id": "OLzUV", + "name": "e1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "uJ52L", + "name": "e1t", + "fill": "#f4f4f5", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Vu6xg", + "name": "e1s", + "fill": "#a1a1aa", + "content": "service", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2nQkb", + "name": "e2", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "x0C8k", + "name": "e2t", + "fill": "#f4f4f5", + "content": "Gmail 工作邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "8srwQ", + "name": "e2s", + "fill": "#a1a1aa", + "content": "account", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "U7n1V", + "name": "listDetailDivider", + "fill": "#26262C", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "ZoXEg", + "name": "detailPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "gap": 32, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "fntXy", + "name": "detailHeaderEmpty", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "YKRfM", + "fill": "#7C7C88", + "content": "请选择左侧条目", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "LqhMu", + "fill": "#F5F5F7", + "content": "未选择条目", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "HipJ9", + "name": "metaEmptySec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "aK4Wy", + "fill": "#7C7C88", + "content": "元数据", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "bgn3I", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 18, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 10, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "4O19i", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "从左侧列表选择一个条目后,这里会显示结构化 Metadata。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lysxJ", + "name": "secretEmptySec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "esZfQ", + "fill": "#7C7C88", + "content": "密钥", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "V9Wr5", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 10, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "vOOKR", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "受保护的密钥字段会在选中条目后显示在这里。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "FyPtE", + "x": 4667, + "y": 0, + "name": "Secrets 主页面 - 空列表态", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#0A0A0B", + "cornerRadius": 24, + "children": [ + { + "type": "frame", + "id": "MeSUb", + "name": "sidebar", + "width": 248, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 20, + 16 + ], + "children": [ + { + "type": "frame", + "id": "GxZsL", + "name": "userRow", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "XW2NB", + "name": "avatar", + "width": 36, + "height": 36, + "fill": "#D97706", + "cornerRadius": 999, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pzf4z", + "name": "avatarTxt", + "fill": "#f4f4f5", + "content": "V", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "14LlE", + "name": "userCopy", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Pom04", + "name": "nameTxt", + "fill": "#f4f4f5", + "content": "用户姓名", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Iy7h1", + "name": "emailTxt", + "fill": "#a1a1aa", + "content": "user@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qcMED", + "name": "folderStack", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "t37OO", + "name": "allItems", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LMnmf", + "name": "allT", + "fill": "#9ca3af", + "content": "所有项目", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "YObBl", + "name": "allCountBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "boZgN", + "name": "allC", + "fill": "#9ca3af", + "content": "17", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yYndB", + "name": "f1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bplXt", + "name": "f1t", + "fill": "#a5b4fc", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "a8eu5", + "name": "f1countBadge", + "fill": "#2A2440", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pPg0i", + "name": "f1c", + "fill": "#a5b4fc", + "content": "12", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "4QT4Y", + "name": "f2", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TWlMB", + "name": "f2t", + "fill": "#9ca3af", + "content": "personal", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "frame", + "id": "zZ6Wj", + "name": "f2countBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "7P6Fo", + "name": "f2c", + "fill": "#9ca3af", + "content": "5", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JFGb8", + "name": "folderTrashGap", + "width": "fill_container", + "height": 32 + }, + { + "type": "frame", + "id": "PzaAo", + "name": "trashFolder", + "width": "fill_container", + "fill": "#0F0F12", + "cornerRadius": 12, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vDKZM", + "name": "trashT", + "fill": "#71717a", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "DlDdT", + "name": "trashC", + "fill": "#71717a", + "content": "2", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "8lTJS", + "name": "sidebarSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "XYJs7", + "name": "mcpEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UYZCK", + "name": "mcpIcon", + "width": 18, + "height": 18, + "iconFontName": "plug", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "qR2eH", + "name": "mcpTxt", + "fill": "#9ca3af", + "content": "MCP", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "XMQ1X", + "name": "logoutEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "92rwF", + "name": "logoutIcon", + "width": 18, + "height": 18, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "CMyGv", + "name": "logoutTxt", + "fill": "#9ca3af", + "content": "退出登录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ieRN9", + "name": "mainArea", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Tdw2W", + "name": "contentRow", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "children": [ + { + "type": "frame", + "id": "5DJPo", + "name": "listColumn", + "width": 404, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "QssU2", + "name": "searchShell", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 18, + 18, + 14, + 18 + ], + "children": [ + { + "type": "frame", + "id": "NpWG8", + "name": "searchInput", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "children": [ + { + "type": "text", + "id": "N9Ddp", + "name": "searchPlace", + "fill": "#71717a", + "content": "按 名称 模糊搜索 ", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ez4Rf", + "name": "listPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "S1cx9", + "name": "toolbar", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 18 + ], + "children": [ + { + "type": "frame", + "id": "SqDMz", + "name": "filterMock", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 10, + 12 + ], + "children": [ + { + "type": "text", + "id": "llkUA", + "fill": "#F5F5F7", + "content": "全部类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zX7P7", + "name": "listBody", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "gKkXQ", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "DHnv4", + "fill": "#F5F5F7", + "content": "没有匹配的条目", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "QGuu3", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "调整搜索词、类型筛选或文件夹后再试。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "Eeipb", + "name": "listDetailDivider", + "fill": "#26262C", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "DT6V0", + "name": "detailPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "gap": 32, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "79GC4", + "name": "detailHeaderEmpty", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "E9DnS", + "fill": "#7C7C88", + "content": "当前筛选下没有条目", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "DKbL5", + "fill": "#F5F5F7", + "content": "没有匹配结果", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "A9u85", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "0lgwu", + "fill": "#7C7C88", + "content": "提示", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "1yBfC", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 18, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "joLTu", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "你可以切换文件夹、清空搜索,或选择其他类型筛选。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "VXGoB", + "x": 6227, + "y": 0, + "name": "Secrets 主页面 - 编辑态", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#0A0A0B", + "cornerRadius": 24, + "children": [ + { + "type": "frame", + "id": "cJFNI", + "name": "sidebar", + "width": 248, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 20, + 16 + ], + "children": [ + { + "type": "frame", + "id": "r8K2Q", + "name": "userRow", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BHrbX", + "name": "avatar", + "width": 36, + "height": 36, + "fill": "#D97706", + "cornerRadius": 999, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "xB6lH", + "name": "avatarTxt", + "fill": "#f4f4f5", + "content": "V", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "GbZYJ", + "name": "userCopy", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "jUHtd", + "name": "nameTxt", + "fill": "#f4f4f5", + "content": "用户姓名", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "wtGai", + "name": "emailTxt", + "fill": "#a1a1aa", + "content": "user@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "pKxX8", + "name": "folderStack", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "3fZlB", + "name": "allItems", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dGrFs", + "name": "allT", + "fill": "#9ca3af", + "content": "所有项目", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "86y2u", + "name": "allCountBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TLXFf", + "name": "allC", + "fill": "#9ca3af", + "content": "17", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vP8Gv", + "name": "f1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FnseU", + "name": "f1t", + "fill": "#a5b4fc", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "VdfaT", + "name": "f1countBadge", + "fill": "#2A2440", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GK5eH", + "name": "f1c", + "fill": "#a5b4fc", + "content": "12", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "IDYC9", + "name": "f2", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0zuD5", + "name": "f2t", + "fill": "#9ca3af", + "content": "personal", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "frame", + "id": "fhxBx", + "name": "f2countBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GMcuJ", + "name": "f2c", + "fill": "#9ca3af", + "content": "5", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "mdBIA", + "name": "folderTrashGap", + "width": "fill_container", + "height": 32 + }, + { + "type": "frame", + "id": "msGed", + "name": "trashFolder", + "width": "fill_container", + "fill": "#0F0F12", + "cornerRadius": 12, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fNUto", + "name": "trashT", + "fill": "#71717a", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "WFEat", + "name": "trashC", + "fill": "#71717a", + "content": "2", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "i46iI", + "name": "sidebarSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "dohXG", + "name": "mcpEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mSwR0", + "name": "mcpIcon", + "width": 18, + "height": 18, + "iconFontName": "plug", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "bKHeJ", + "name": "mcpTxt", + "fill": "#9ca3af", + "content": "MCP", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "HOVlB", + "name": "logoutEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "2HJpm", + "name": "logoutIcon", + "width": 18, + "height": 18, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "eqA20", + "name": "logoutTxt", + "fill": "#9ca3af", + "content": "退出登录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "iA8Fm", + "name": "mainArea", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vzEoC", + "name": "contentRow", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "children": [ + { + "type": "frame", + "id": "BltwF", + "name": "listColumn", + "width": 404, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "oJvEA", + "name": "searchShell", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 18, + 18, + 14, + 18 + ], + "children": [ + { + "type": "frame", + "id": "jjUiU", + "name": "searchInput", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "children": [ + { + "type": "text", + "id": "aA3UK", + "name": "searchPlace", + "fill": "#71717a", + "content": "按 名称 模糊搜索 ", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "M7h41", + "name": "listPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#3f3f46" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "NWKvF", + "name": "toolbar", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 18 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "By8gf", + "name": "filterMock", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 10, + 12 + ], + "children": [ + { + "type": "text", + "id": "KV2SY", + "name": "filterTxt", + "fill": "#f4f4f5", + "content": "全部类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "pvLix", + "name": "listBody", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 14, + 18, + 18, + 18 + ], + "children": [ + { + "type": "frame", + "id": "Ijevb", + "name": "e1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "YEEak", + "name": "e1t", + "fill": "#f4f4f5", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "tKZLf", + "name": "e1s", + "fill": "#a1a1aa", + "content": "service", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wMSHw", + "name": "e2", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "x26c4", + "name": "e2t", + "fill": "#f4f4f5", + "content": "Gmail 工作邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "F12UI", + "name": "e2s", + "fill": "#a1a1aa", + "content": "account", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "9zUx6", + "name": "listDetailDivider", + "fill": "#26262C", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "PxRZs", + "name": "detailPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "gap": 28, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "cbvHb", + "name": "editHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "OoyvV", + "name": "editLeft", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "1S0zW", + "name": "editFolder", + "fill": "#7C7C88", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "piQO9", + "name": "editTitle", + "fill": "#F5F5F7", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "qHqjJ", + "name": "editActions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0HZDG", + "name": "saveBtn", + "fill": "#1B2740", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#355D9A" + }, + "gap": 8, + "padding": [ + 10, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4p10c", + "name": "saveIcon", + "width": 14, + "height": 14, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "#93C5FD" + }, + { + "type": "text", + "id": "7Qaji", + "name": "saveTxt", + "fill": "#DBEAFE", + "content": "保存", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Myamj", + "name": "cancelBtn", + "fill": "#1A1A1D", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "gap": 8, + "padding": [ + 10, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4oNYV", + "name": "cancelIcon", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + }, + { + "type": "text", + "id": "nSs88", + "name": "cancelTxt", + "fill": "#D4D4D8", + "content": "取消", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zbTb5", + "name": "nameSec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "dxdgW", + "name": "nameLab", + "fill": "#7C7C88", + "content": "名称", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "lLwVL", + "name": "nameInput", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "text", + "id": "vdMyp", + "name": "nameVal", + "fill": "#F5F5F7", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zaIwk", + "name": "metaSec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "pcIWM", + "name": "metaLab", + "fill": "#7C7C88", + "content": "元数据", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "Mo813", + "name": "metaStack", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "jqcGY", + "name": "metaRow1", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "Lyye4", + "name": "metaKey1", + "width": 180, + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "text", + "id": "P8KPX", + "name": "metaKey1Txt", + "fill": "#B3B3BD", + "content": "HOST", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Evj4o", + "name": "metaVal1", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "text", + "id": "34Lii", + "name": "metaVal1Txt", + "fill": "#F5F5F7", + "content": "git.example.com", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "4oWBa", + "name": "metaRow2", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "CNojB", + "name": "metaKey2", + "width": 180, + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "text", + "id": "TiquA", + "name": "metaKey2Txt", + "fill": "#B3B3BD", + "content": "USERNAME", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dMI47", + "name": "metaVal2", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "text", + "id": "LSnMX", + "name": "metaVal2Txt", + "fill": "#F5F5F7", + "content": "developer_admin", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "piptM", + "name": "addMetaBtn", + "fill": "#17171A", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "WoqJN", + "name": "addMetaTxt", + "fill": "#F5F5F7", + "content": "新增元数据", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "P3maG", + "name": "secretSec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "4S352", + "name": "secretLab", + "fill": "#7C7C88", + "content": "密钥", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "V68UM", + "name": "secretCard", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 18, + "padding": 22, + "children": [ + { + "type": "text", + "id": "pPLWn", + "name": "secretType", + "fill": "#7C7C88", + "content": "访问令牌", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.08 + }, + { + "type": "frame", + "id": "N1Ra8", + "name": "secretRow", + "width": "fill_container", + "gap": 14, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Zilga", + "name": "secretShell", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "text", + "id": "qjs6V", + "name": "secretValue", + "fill": "#B3B3BD", + "content": "••••••••••••", + "fontFamily": "JetBrains Mono", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TOcKG", + "name": "secretCopy", + "fill": "#17171A", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "l1AVx", + "name": "secretCopyTxt", + "fill": "#F5F5F7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "kJC1w", + "x": 2027, + "y": 923, + "name": "设备在线弹窗", + "clip": true, + "width": 960, + "height": 803, + "fill": "#111113", + "cornerRadius": 24, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000044", + "offset": { + "x": 0, + "y": 12 + }, + "blur": 24 + }, + "children": [ + { + "type": "frame", + "id": "WG0aO", + "name": "mcpMain", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "layout": "vertical", + "gap": 28, + "padding": 28, + "children": [ + { + "type": "frame", + "id": "SW1W8", + "name": "deviceListSec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "text", + "id": "jMSO7", + "fill": "#F5F5F7", + "content": "设备在线列表", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "ewkns", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "查看当前已登录设备的在线情况与最近活动。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "G7zwo", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "PKM6K", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 18, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 6, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "zxMW4", + "fill": "#F5F5F7", + "content": "Mac mini", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "6C9Zf", + "fill": "#B3B3BD", + "content": "macOS · Secrets Desktop 0.1.0", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "9pt66", + "fill": "#F5F5F7", + "content": "最后活动: 刚刚", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "jKp2X", + "name": "ip1", + "fill": "#B3B3BD", + "content": "IP: 192.168.31.10", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UeCA6", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 18, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 6, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "Z4WHm", + "fill": "#F5F5F7", + "content": "MacBook Pro", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "0KLxK", + "fill": "#B3B3BD", + "content": "macOS · Secrets Desktop 0.1.0", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "9uqDW", + "fill": "#F5F5F7", + "content": "最后活动: 5 分钟前", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "xDelo", + "name": "ip2", + "fill": "#B3B3BD", + "content": "IP: 192.168.31.24", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "08CCM", + "name": "deviceNoteSec", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "Bl8tU", + "fill": "#7C7C88", + "content": "说明", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + }, + { + "type": "frame", + "id": "rZYQ2", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 18, + 20 + ], + "children": [ + { + "type": "text", + "id": "Fh9A1", + "fill": "#B3B3BD", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "设备列表用于确认当前登录设备与最近活动。若检测到异常设备,应及时退出登录并重新签发 token。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "CQXqU", + "x": 7787, + "y": 0, + "name": "Secrets 主页面 - 最近删除查看页", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#0A0A0B", + "cornerRadius": 24, + "children": [ + { + "type": "frame", + "id": "Qhune", + "name": "sidebar", + "width": 248, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 20, + 16 + ], + "children": [ + { + "type": "frame", + "id": "IV1VD", + "name": "userRow", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vlfyL", + "name": "avatar", + "width": 36, + "height": 36, + "fill": "#D97706", + "cornerRadius": 999, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "63VP4", + "name": "avatarTxt", + "fill": "#f4f4f5", + "content": "V", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "B5M96", + "name": "userCopy", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "auLSE", + "name": "nameTxt", + "fill": "#f4f4f5", + "content": "用户姓名", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ru72i", + "name": "emailTxt", + "fill": "#a1a1aa", + "content": "user@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lrmSg", + "name": "folderStack", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "G2xiN", + "name": "allItems", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YOrRw", + "name": "allT", + "fill": "#9CA3AF", + "content": "所有项目", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "WUVLB", + "name": "allCountBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AhIfb", + "name": "allC", + "fill": "#9ca3af", + "content": "17", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "THwLy", + "name": "f1", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kS9kD", + "name": "f1t", + "fill": "#9CA3AF", + "content": "refining", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "frame", + "id": "955SM", + "name": "f1countBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K0J3P", + "name": "f1c", + "fill": "#9CA3AF", + "content": "12", + "fontFamily": "Inter", + "fontSize": 12 + } + ] + } + ] + }, + { + "type": "frame", + "id": "KF91R", + "name": "f2", + "width": "fill_container", + "fill": "#111113", + "cornerRadius": 14, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IM3p3", + "name": "f2t", + "fill": "#9ca3af", + "content": "personal", + "fontFamily": "Inter", + "fontSize": 14 + }, + { + "type": "frame", + "id": "dixZi", + "name": "f2countBadge", + "fill": "#1D1D22", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 9 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WwMUD", + "name": "f2c", + "fill": "#9ca3af", + "content": "5", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "4y63N", + "name": "folderTrashGap", + "width": "fill_container", + "height": 32 + }, + { + "type": "frame", + "id": "1BR0X", + "name": "trashFolder", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 12, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JPUIT", + "name": "trashT", + "fill": "#C4B5FD", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "f8wdw", + "name": "trashC", + "fill": "#C4B5FD", + "content": "2", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "DELPd", + "name": "sidebarSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "DaqoW", + "name": "mcpEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zeJxs", + "name": "mcpIcon", + "width": 18, + "height": 18, + "iconFontName": "plug", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "yID9H", + "name": "mcpTxt", + "fill": "#9ca3af", + "content": "MCP", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "dzJix", + "name": "logoutEntry", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "YKpM9", + "name": "logoutIcon", + "width": 18, + "height": 18, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "#9ca3af" + }, + { + "type": "text", + "id": "FNwwc", + "name": "logoutTxt", + "fill": "#9ca3af", + "content": "退出登录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "UWbm9", + "name": "mainArea", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "rQGrk", + "name": "contentRow", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "children": [ + { + "type": "frame", + "id": "2lf3j", + "name": "listColumn", + "width": 404, + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#26262C" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "GVb3A", + "name": "searchShell", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 18, + 18, + 14, + 18 + ], + "children": [ + { + "type": "frame", + "id": "K0glf", + "name": "searchInput", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 12, + 14 + ], + "children": [ + { + "type": "text", + "id": "BLl0q", + "name": "searchPlace", + "fill": "#71717a", + "content": "按名称搜索最近删除项目", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lb2qM", + "name": "listPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#3f3f46" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "EhPYD", + "name": "toolbar", + "width": "fill_container", + "fill": "#111113", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#26262C" + }, + "padding": [ + 14, + 18 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "93GLM", + "name": "filterMock", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "padding": [ + 10, + 12 + ], + "children": [ + { + "type": "text", + "id": "YjiBx", + "name": "filterTxt", + "fill": "#f4f4f5", + "content": "全部类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nNhaF", + "name": "listBody", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 14, + 18, + 18, + 18 + ], + "children": [ + { + "type": "frame", + "id": "5QjDy", + "name": "e1", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "k0PGF", + "name": "e1t", + "fill": "#f4f4f5", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "xF27G", + "name": "e1s", + "fill": "#a1a1aa", + "content": "30 天内可恢复", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "omwED", + "name": "e2", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 6, + "padding": 16, + "children": [ + { + "type": "text", + "id": "FatYm", + "name": "e2t", + "fill": "#f4f4f5", + "content": "Gmail 工作邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "0y7b5", + "name": "e2s", + "fill": "#a1a1aa", + "content": "7 天前删除", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "9SYKG", + "name": "listDetailDivider", + "fill": "#26262C", + "width": 1, + "height": "fill_container" + }, + { + "type": "frame", + "id": "4dJxy", + "name": "detailPane", + "width": "fill_container", + "height": "fill_container", + "fill": "#0A0A0B", + "stroke": { + "align": "inside", + "thickness": 0, + "fill": "#3f3f46" + }, + "layout": "vertical", + "gap": 32, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "Wi4qI", + "name": "dh", + "width": "fill_container", + "gap": 20, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zTbr3", + "name": "detailLeft", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "SyRP2", + "name": "folderLabel", + "fill": "#7C7C88", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "k2izH", + "name": "dtitle", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lwvUh", + "name": "h2", + "fill": "#F5F5F7", + "content": "gitea", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "RFN1A", + "name": "badge", + "fill": "#422006", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g5vKc", + "name": "badgeTxt", + "fill": "#FBBF24", + "content": "最近删除", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "7vez0", + "name": "dactions", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ir7ZV", + "x": 0, + "y": 0, + "name": "btnEdit", + "enabled": false, + "fill": "#17171A", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "padding": [ + 10, + 16 + ], + "children": [ + { + "type": "text", + "id": "Iqk0d", + "name": "btnEditTxt", + "fill": "#F5F5F7", + "content": "编辑", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "ag4k3", + "name": "btnDel", + "fill": "#1A1A1D", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "gap": 8, + "padding": [ + 10, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "TwRLc", + "name": "restoreIcon", + "width": 14, + "height": 14, + "iconFontName": "rotate-ccw", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + }, + { + "type": "text", + "id": "lw9lQ", + "name": "btnDelTxt", + "fill": "#D4D4D8", + "content": "恢复", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zdfqx", + "name": "secMeta", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "frame", + "id": "pTjhG", + "name": "metaHdr", + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "2oBrr", + "name": "metaTitle", + "fill": "#7C7C88", + "content": "元数据", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + } + ] + }, + { + "type": "rectangle", + "id": "3AqNX", + "x": 0, + "y": 40, + "name": "metaDivider", + "enabled": false, + "fill": "#2a2a2e", + "width": "fill_container(731)", + "height": 1 + }, + { + "type": "frame", + "id": "BaR3u", + "name": "metaList", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "cO0ei", + "name": "row1", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "jrlj5", + "name": "t1", + "fill": "#7C7C88", + "textGrowth": "fixed-width", + "width": 100, + "content": "HOST", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "RQzmH", + "name": "valFrame1", + "width": "fill_container", + "gap": 12, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "KSXDh", + "name": "v1", + "fill": "#F5F5F7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "git.example.com", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "Vk9Uj", + "name": "c1", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Q0Wli", + "name": "row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "MaDeF", + "name": "t2", + "fill": "#7C7C88", + "textGrowth": "fixed-width", + "width": 100, + "content": "USERNAME", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "Qqnhp", + "name": "valFrame2", + "width": "fill_container", + "gap": 12, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "hJMZz", + "name": "v2", + "fill": "#F5F5F7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "developer_admin", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "0WPER", + "name": "c2", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RJyQ1", + "name": "row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "h2zK3", + "name": "t3", + "fill": "#7C7C88", + "textGrowth": "fixed-width", + "width": 100, + "content": "创建时间", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "N5SZs", + "name": "valFrame3", + "width": "fill_container", + "gap": 12, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "zKPJ5", + "name": "v3", + "fill": "#F5F5F7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2023-12-02 09:18:11 UTC", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "tdsXi", + "name": "c3", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "B1nC2", + "name": "secSec", + "width": "fill_container", + "layout": "vertical", + "gap": 18, + "children": [ + { + "type": "frame", + "id": "HtHWt", + "name": "secHdr", + "width": "fill_container", + "children": [ + { + "type": "text", + "id": "vscGa", + "name": "shTxt", + "fill": "#7C7C88", + "content": "密钥", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.12 + } + ] + }, + { + "type": "frame", + "id": "X3D7k", + "name": "secretCard", + "width": "fill_container", + "fill": "#17171A", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "gap": 18, + "padding": 22, + "children": [ + { + "type": "frame", + "id": "aHDJz", + "name": "secretTitleCol", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "TgsIH", + "name": "nameRow", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "XYTkE", + "name": "nVal", + "fill": "#7C7C88", + "content": "访问令牌", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.08 + } + ] + } + ] + }, + { + "type": "frame", + "id": "n3On9", + "name": "secretValueRow", + "width": "fill_container", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rfXs2", + "name": "secretValueShell", + "width": "fill_container", + "fill": "#141418", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#26262C" + }, + "layout": "vertical", + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "dCQA2", + "name": "leftPk", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "i5dsT", + "name": "lockIco", + "width": 16, + "height": 16, + "iconFontName": "lock", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + }, + { + "type": "text", + "id": "lLtYX", + "name": "maskTxt", + "fill": "#B3B3BD", + "content": "••••••••••••", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "8ZHGG", + "name": "secretSpacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon_font", + "id": "sJ1bh", + "name": "eyeIc", + "width": 16, + "height": 16, + "iconFontName": "eye-off", + "iconFontFamily": "lucide", + "fill": "#7C7C88" + } + ] + } + ] + }, + { + "type": "frame", + "id": "BoF2S", + "name": "copyBtn", + "fill": "#17171A", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#34343D" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "MNCw0", + "name": "cpIcon", + "width": 14, + "height": 14, + "iconFontName": "copy", + "iconFontFamily": "lucide", + "fill": "#B3B3BD" + }, + { + "type": "text", + "id": "hbuQY", + "name": "cpLbl", + "fill": "#F5F5F7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..0b80b52 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "secrets-desktop" +version = "3.0.0" +edition.workspace = true + +[build-dependencies] +tauri-build.workspace = true + +[dependencies] +anyhow.workspace = true +axum.workspace = true +chrono.workspace = true +hex.workspace = true +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +tauri.workspace = true +tokio.workspace = true +reqwest.workspace = true +sha2.workspace = true +url.workspace = true +uuid.workspace = true +base64 = "0.22.1" + +secrets-client-integrations = { path = "../../../crates/client-integrations" } +secrets-crypto = { path = "../../../crates/crypto" } +secrets-device-auth = { path = "../../../crates/device-auth" } +secrets-domain = { path = "../../../crates/domain" } + +[[bin]] +name = "Secrets" +path = "src/main.rs" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/check_png_center.js b/apps/desktop/src-tauri/check_png_center.js new file mode 100644 index 0000000..f7f9e58 --- /dev/null +++ b/apps/desktop/src-tauri/check_png_center.js @@ -0,0 +1,2 @@ +const fs = require('fs'); +// Very simple check: read the first few bytes, maybe we can use an image library to find the bounding box diff --git a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json new file mode 100644 index 0000000..43da9ef --- /dev/null +++ b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json new file mode 100644 index 0000000..260dbe0 --- /dev/null +++ b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -0,0 +1,2244 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/macOS-schema.json b/apps/desktop/src-tauri/gen/schemas/macOS-schema.json new file mode 100644 index 0000000..260dbe0 --- /dev/null +++ b/apps/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -0,0 +1,2244 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..51d0a29272de7344bfe6796027c65f6ce09e100c GIT binary patch literal 6409 zcmbVQc_38X`#<;2U=o>#EXA}*2t`z4Zl#Sx*(%AXY;OrKp~PGgZ`!;~iELBZD@&W* zj7k#OYmkg2JA($9^?UVxzyJK!_xt_p+c4FHhcx7Xqj z05J9m1A-(ra6{U704O)@v)FDQ(mD3U`kJG|J*kN-Kt?jW$nlht-*H2^>k|&sn$-5= zon){2Gp~CXshB1@Uh=p%ZqfRZ7MK*h;H+wv=k^l$guVK!7t7cAxW!(7Rv48*cgQ%T zb>f3rwX^XPblRW8?+$I`+$}#3y*NJHbvi{H))3I>q6m;6M->#t5FNOSz^gBXv|D;*&WeBc|?u3>xB(SWkJc z6rcCps4-YBr>64mQ|$q*guR@|2!MoY3XE#W7e82VwAByqou-&8Cu{GhidwN4c)d+$ zR2X(}29JHm-Cj&Mpybw*{gnu^osLjLJzufiEYR5HL*+M=A}B3>1iyAwV_Y`g>pn&Ai$0n9gY1egHM+X!-UI5#!JNi@>Lqm_hZ8vB;}AKA+YoXEMkvisWsNDOsg3x5{>c(xRxXVJu=pZL&& z&tVW}0R$uYrb25Gs8|TSj@|(V`}-KCfjnH>8vH1xBBV%DymL29H(s;=`l^TO-4?Qq z<-v>F3&gj1-#!MKt5=)Q`ei4f?@5sMHyyblCRoBB~}8 z?RS6$qe>ut)W=i+S=a^3v^`%Bbccq;HeajEfwghh+>rx=kYAYw65gVKzo{$444*$G83pY z!e1-?qDcjL8=4Z;?7=Z1jxJ4ydxkZD15gA`Vvr4IgJPZ(qKHT^KaxQ%&<5uc1u>Y` zbqk~obiwl)-{e-1V7M}E4Z5;cHZb+46!@wF&Dn$0bLzN)y=4x-NDLA0K@Q;FY?B6! z3<{$>n9E@#V9ak3j=#dr|g_cAYp-!-?8DQ~S*2gr6%To-s(S8B|W(ZUcTI$&SY zUEq;Y1aw0m=w<_>M|=(NzVpCw;Pr=FmM(Bp>4|oH!KL^FSHiZpVs^?Q?R*V%;tX1V zC%zYh=WGgQn>g^Up+FriaKs^lID#2)9!am@FBuH-1a9l;cOG3fwkC| z07fw^?oW7^zn}?+f+j-3q$3j`r<(BXpRzVVl)Qsbaml#@*5Dn-7?DLm)cS~N4B}Mq znBKsv{m3)WBeD2*tDj~y5gX=Ft^BWJ3ryHv6tE69Z<#dlHN1#i8$>cBOrq3yA08bQk`0wvJ=dWRWZF zy6?nQc|K0W%O4qCg<{4cfaff=U6Ty`(^+eufo zYe{>dp6{;R?)v~VuMNT)aecaV)S*lCH4r8dL;Gf$ct$LEJY6)qOQL9@x^TAaGQgwK zInTE5YIzc-DL&kLt9G=jJp)K&gKW^G9J9x>|C3C;L2}fAX zSy~pKYRR!Pe751J?#bkJ;VJQknVuK;jCR+w<4bSKd>LQ8Q{varWvQ%VU$SDx(ibpt z-QtcNQNv`~5N=zE2Aj1DN zfHy7eE8AtTGfd>&YZE%ia<|?#y)E5SHBg6;9tPhK@y;emtoX&P;)U=ZTjO0NrEKOWz36Pd-ZN`UP|bRNjj|D7*D3vwJ1M z-a&S6mDXh-#17-8Lm7c zfe6q94o@SWvb4OdIaTxBODp!b0L88kaX0pDHAcMPsBHSA@8D${EB2UvP1Y9&fBvb? zHGZSBv)%*?1q??lhOSfHZA3Y%Q@`1bw+Q*}`cmm9AFd$c!5>e{KGdBRB)R@*bWk*d zhLkfbWwFDqTXc8ZT-N8_-%|zovXDD_Wyr*vU28FKCT2VqGW>fV9Wt4lOUt5qP)u76 zr*Bwiwbd~fd-%vYr`zs%LXLjkg`}4+eDAaz9@Q^Yy>;V`Hl`)p_loXEQoBku)m2Kr z&_#$U&B*xNHVGedkB2c7VzEE8!J!Z!;ffpn>%*g%~&;zV&Bw;vp;X_`#b= z<4OtYsC~^zDGXDNf1tXXH3ZdND$*9#yi!-c{We!-sNGulQI)Bx?2K!VU+vkTHY<~#(_yyqm%7nq zCN~RhZagwm8JJl0NCtXy^WIxL8YF@GTLC%@Oj5KO zj_Z9NIdfFs`EJJJ!fm0V*eYymp6_oSK^?BE%v!t0s0V-3K-NJ$k@G>s;@pQ5Nyc3# zNDss7fTv~dI5LQ>_7s}w$nKD>d46qp5dkn|eTtocK_&^z?`TUpFzGdIsE+9=Z{*m5 zlwLJ1xShu+ezx+Alnh4hxG?fDR9O1 zJ36N$d0C^wkMq9Wukn5RRL>JOqo-8Cb33Nl>>Q)o`M0=PP_=)*OWA;QzV6RiG^ zlYO5|`(OQv)|-saO_qgBKGE-;7`#?RH!6Gi_{Lr8d8ftFj>|vIE~zV>3ilWYT;^Ny{59|aOj@QJHjDv!rlQA0W|I6TyxPr`=iW@G?){(C^KO3 z4mr#Kq|(B1GWS(hsqji^*K&gyKA(#lglr)+x8lO66^*<}I{jI|z-r9z95@qMT${GD z>zGQdm@nPGR6SE^yT|*pHxbZRQccI=$-~|jSTceBR-TsHpH*+!7JU9U!Kgbf0)T^6 zC&TVH+<0WWI0(j`$bc3B6kKer7K(e8NkC}*wKV=G(){Ott=aw;6Vii>^IR2$ryVWC z!<8j$n-BGG?I(#zhBJjy5b(M=J^T2Y&(5Oicq!lOd!o-Wh8X;Y_KeNuGt*rseHvYg zPfQ>H-1G49zbt;=@S^Z^-LQn%WF4tKChGM=!@}TGTQjdpLnN%go#=WP{MOEyRey(f zS6d>+e#_>N-V=ha?RO;=v81KJNytwd6xSXS@5lg?7Z+XYS9#9&m_T7)^lp$^aV{1kH>HgRw7hj51jB33ji4CTpgCIBRV ztgHOO!4y&59dn&9P3$0Mx9@R(|G6JKbuXugKcAwo$R=U^v}&Jd6sx+KdFt`KX`L5h zaG1{!&DvWTOP$0lN2rC3vlW z20ED-i@aaWum4x&jQTAFMqNKU>O_k$86{f+#y_Wumt%hMHUe|U!OYpy26H!9q$o_} zsqem7`qgdKoC`B^L(f`&NzFUJeCB^~bR;z_oHxo1#(;-jCz@cmddziT?2ed*iY%91 zoPCr>JCq^sT>Bh33$C%|lBbg(p*T^4mk zkhs%sL-@EejsSxud*9^Z%_jHtJ*LRpww~<4w%yaS9^9#aQMIN<@5=;xpIBFDs!1gT zL!db~lSwr5~=e!E{8-$PlzTYKz(_Rt)K6f;EZE6L{rt@hmhGhS; z`v3R$wCEMFX^9%E?$PEw+a=IZZvLN6{x>3}K}QFMjFQlRRyOE~(P^uDS@l^4)&{mO zV`4#pDD{qHN9mO~lF_J7Sd2#zuRzc@P?m)tPN)0;#xybJIz)PIdK9OQVw81ECC*v%pPSt z4R{hkSDGO=`%div=-G}J?k_UHh4I=ar+fdr|5+9fgTU5^-uHE3EDJf#oYR#Z03CFh z`zX1cdt%CFKs`#6*@WL~Vix9sI{v=bTSa>bW;B7Tun-kkjH;#>i2f1g( z_ER>(wSPfz8%L?CVwG#Qq%9B2&rCTjV>hukWEiIReib~P`U~-0ey?ogesmOve>`?F=QDpk8wFl2;P7`_7-^TH zSD6QHh74GJPEbPNahQ@^1aU_P~uQb#+tjFsd6D&tNOfjvg)+y{}k03huP3`Qjh zs0&sn1I(ilr{v%FLIyQ`m~X={<;E33JkU%|0|_Um7GfncHm+opflrFQX{LK1KPjZI z6=%CFY2Qr=NzyjS&1ITQuU0*4kl;KYI&j$I{UyxLUnL%#sFb5T2;sjekw*5JkJhh; z5!^EWN|>l!*CBz@8l1o}``zk%5VaNu_lU%gbwKUGto5M(7FNHS0}wy*;gh5Bg9&H>JKn9>&Ru`>7fASA8Ehd8)Ka|m3Khv{U z%VmSTejuu!tcZFzfJ2e|_7WN9%56<=$EeS8LExgEbpsp0!ob`y8dc8<)S3?%g8pmQ z;=v9>61YF7&yq&5%lF4F)4@6iSRbMB8I0wu=$4f=zX*AvACIvP1E8FJS2#~V#+}3^ zj^LJO3J3IUTg&}^;rUfs)%Gf(B=3RVt0WN(?kCZ~R!X`oSOAi4s4 zV~Ot`Ap^u+f0&XcJl{xdxWu^&YsNY;nt|7goZd}w{ErN2CRtBn6l)zlDSF+f503e+ zW4d5qFq|i*MOVoqU6ggClx1WSRq$lWZBjL8Iv<{}vx4k)<4B#)#~GioExM;J(Z67w zs7*n^;V)N#K`HRZ#ZN-4O=LFTl>vXY3Pq+emjHbokz^!X0oqp+NM~jgE`Z|N6zKK1 z4DhP&R?hRq?&U85NV_?~e9_KRRd zas`EyH@;N@v9Em1M(25d_Sl|vO%idx(b=~LWV;izj=xS_Ngz6yee{i~byc9Wm2WZd zQRaGhEyDjH|A`+Qnw~F>*yTcdwpzhF7@X*^;Tp~@1lP-VGH(H9Z$(}!TOK(q4+u$o zI^5K>CL*G^vq~E2DyX9}F>O7c)QhlR5b`)A?1TlVyc@1*o>}J#-rZL<$Tk6_eilz^ z6K@FmLndv_L{{%~K{MDGkQ)%nj=FCPy_-4^(a^s=UIKmhxQl!SyCgp10oS2iGS+o` zF&VTsl?+82OMl(|Sjgq{_#G(q|PDf;y$w2$xdV-BurLVV4FF z-$-WZ=}2bKD%fKB^R#;5OMPJ5_@3!EFdeW8elw*}8~W66D9vaY3_c90C@&$PYxe&f zI@fux5+G&cTFRr_Qv8}yB#0%!qiK4_6;f)u6){R?6>008$U%HmL_>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalVaultBootstrap { + pub unlocked: bool, + pub has_master_password: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalVaultEntrySummary { + pub id: String, + pub name: String, + pub cipher_type: String, + pub folder: String, + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSecretField { + pub id: String, + pub name: String, + pub secret_type: String, + pub masked_value: String, + pub version: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalDetailField { + pub label: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalEntryDetail { + pub id: String, + pub name: String, + pub folder: String, + pub cipher_type: String, + pub metadata: Vec, + pub secrets: Vec, + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSecretValue { + pub id: String, + pub entry_id: String, + pub name: String, + pub secret_type: String, + pub value: String, + pub version: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalHistoryItem { + pub history_id: i64, + pub secret_id: String, + pub name: String, + pub secret_type: String, + pub masked_value: String, + pub value: String, + pub version: i64, + pub action: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalEntryDraft { + pub folder: String, + pub name: String, + pub cipher_type: String, + pub metadata: Vec, + pub secrets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSecretDraft { + pub name: String, + pub secret_type: Option, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSecretUpdateDraft { + pub id: String, + pub name: Option, + pub secret_type: Option, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalEntryQuery { + pub folder: Option, + pub cipher_type: Option, + pub query: Option, + pub deleted_only: bool, +} + +pub async fn open_or_create_local_vault() -> Result { + let path = local_vault_path()?; + open_or_create_local_vault_at(&path).await +} + +async fn open_or_create_local_vault_at(path: &std::path::Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display())) + .context("failed to build sqlite connect options")? + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .with_context(|| format!("failed to open local vault {}", path.display()))?; + + migrate_local_vault(&pool).await?; + + Ok(LocalVault { + pool, + unlocked_key: Arc::new(RwLock::new(None)), + }) +} + +pub fn local_vault_path() -> Result { + let home = std::env::var("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home) + .join(".secrets-v3") + .join("desktop") + .join("vault.sqlite3")) +} + +async fn migrate_local_vault(pool: &SqlitePool) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS vault_meta ( + key TEXT PRIMARY KEY, + value BLOB NOT NULL + ); + CREATE TABLE IF NOT EXISTS vault_objects ( + object_id TEXT PRIMARY KEY, + object_kind TEXT NOT NULL, + revision INTEGER NOT NULL, + cipher_version INTEGER NOT NULL, + ciphertext BLOB NOT NULL, + content_hash TEXT NOT NULL, + deleted_at TEXT, + updated_at TEXT NOT NULL, + last_synced_at TEXT + ); + CREATE TABLE IF NOT EXISTS vault_object_history ( + object_id TEXT NOT NULL, + revision INTEGER NOT NULL, + ciphertext BLOB NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (object_id, revision) + ); + CREATE TABLE IF NOT EXISTS pending_changes ( + change_id TEXT PRIMARY KEY, + object_id TEXT NOT NULL, + object_kind TEXT NOT NULL, + operation TEXT NOT NULL, + base_revision INTEGER, + ciphertext BLOB, + content_hash TEXT, + queued_at TEXT NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT + ); + CREATE TABLE IF NOT EXISTS sync_state ( + scope TEXT PRIMARY KEY, + last_server_revision INTEGER NOT NULL DEFAULT 0, + last_full_sync_at TEXT, + last_success_at TEXT, + last_error_at TEXT, + last_error TEXT + ); + CREATE TABLE IF NOT EXISTS search_index_docs ( + object_id TEXT PRIMARY KEY, + doc_ciphertext BLOB NOT NULL, + doc_version INTEGER NOT NULL, + updated_at TEXT NOT NULL + ); + "#, + ) + .execute(pool) + .await + .context("failed to migrate local vault")?; + + Ok(()) +} + +pub async fn bootstrap(vault: &LocalVault) -> Result { + let has_master_password = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM vault_meta WHERE key IN ('key_salt', 'key_check', 'vault_key')", + ) + .fetch_one(&vault.pool) + .await + .context("failed to read local vault bootstrap")? + >= 3; + let unlocked = vault + .unlocked_key + .read() + .map_err(|_| anyhow!("failed to read local unlock state"))? + .is_some(); + Ok(LocalVaultBootstrap { + unlocked, + has_master_password, + }) +} + +pub async fn setup_master_password( + vault: &LocalVault, + password: &str, + config: &KdfConfig, +) -> Result<()> { + let salt = Uuid::new_v4().as_bytes().to_vec(); + let master_key = derive_master_key(password, &salt, config)?; + let vault_key = derive_random_vault_key(); + let key_check = encrypt(&master_key, KEY_CHECK_PLAINTEXT)?; + let protected_vault_key = encrypt(&master_key, &vault_key)?; + + upsert_meta(&vault.pool, "key_salt", salt).await?; + upsert_meta( + &vault.pool, + "key_params", + serde_json::to_vec(config).context("failed to encode key params")?, + ) + .await?; + upsert_meta(&vault.pool, "key_check", key_check).await?; + upsert_meta(&vault.pool, "vault_key", protected_vault_key).await?; + set_unlocked_key( + vault, + Some( + vault_key + .try_into() + .map_err(|_| anyhow!("invalid vault key"))?, + ), + ); + Ok(()) +} + +pub async fn unlock(vault: &LocalVault, password: &str) -> Result<()> { + let salt = get_meta(&vault.pool, "key_salt") + .await? + .context("vault is not initialized")?; + let config_bytes = get_meta(&vault.pool, "key_params") + .await? + .context("missing key params")?; + let config: KdfConfig = serde_json::from_slice(&config_bytes).context("invalid key params")?; + let master_key = derive_master_key(password, &salt, &config)?; + let key_check = get_meta(&vault.pool, "key_check") + .await? + .context("missing key check")?; + let plaintext = + decrypt(&master_key, &key_check).map_err(|_| anyhow!("invalid master password"))?; + if plaintext.as_slice() != KEY_CHECK_PLAINTEXT { + return Err(anyhow!("invalid master password")); + } + let wrapped_vault_key = get_meta(&vault.pool, "vault_key") + .await? + .context("missing vault key")?; + let vault_key = decrypt(&master_key, &wrapped_vault_key)?; + let key: [u8; 32] = vault_key + .try_into() + .map_err(|_| anyhow!("invalid local vault key length"))?; + set_unlocked_key(vault, Some(key)); + Ok(()) +} + +pub fn lock(vault: &LocalVault) { + set_unlocked_key(vault, None); +} + +pub async fn list_entries( + vault: &LocalVault, + query: &LocalEntryQuery, +) -> Result> { + let views = load_all_views(vault).await?; + let mut entries: Vec<_> = views + .into_iter() + .filter(|view| { + let deleted = view.deleted_at.is_some(); + if query.deleted_only != deleted { + return false; + } + if let Some(folder) = query.folder.as_ref() + && &view.folder != folder + { + return false; + } + if let Some(cipher_type) = query.cipher_type.as_ref() + && view.cipher_type.as_str() != cipher_type + { + return false; + } + if let Some(keyword) = query.query.as_ref() { + let keyword = keyword.to_lowercase(); + let haystack = format!( + "{} {} {}", + view.name, + view.folder, + view.notes.clone().unwrap_or_default() + ) + .to_lowercase(); + if !haystack.contains(&keyword) { + return false; + } + } + true + }) + .map(|view| LocalVaultEntrySummary { + id: view.id.to_string(), + name: view.name, + cipher_type: view.cipher_type.as_str().to_string(), + folder: view.folder, + deleted: view.deleted_at.is_some(), + }) + .collect(); + + entries.sort_by(|a, b| b.name.cmp(&a.name)); + Ok(entries) +} + +pub async fn entry_detail(vault: &LocalVault, entry_id: &str) -> Result { + let view = load_view(vault, entry_id).await?; + let (metadata, secrets) = detail_parts(&view); + Ok(LocalEntryDetail { + id: view.id.to_string(), + name: view.name, + folder: view.folder, + cipher_type: view.cipher_type.as_str().to_string(), + metadata, + secrets, + deleted: view.deleted_at.is_some(), + }) +} + +pub async fn reveal_secret_value( + vault: &LocalVault, + entry_id: &str, + secret_name: &str, +) -> Result { + let view = load_view(vault, entry_id).await?; + let value = + secret_value_from_view(&view, secret_name).ok_or_else(|| anyhow!("secret not found"))?; + Ok(LocalSecretValue { + id: format!("{}:{secret_name}", view.id), + entry_id: view.id.to_string(), + name: secret_name.to_string(), + secret_type: infer_secret_type(secret_name).to_string(), + value, + version: current_revision(vault, &view.id).await?, + }) +} + +pub async fn secret_history( + vault: &LocalVault, + entry_id: &str, + secret_name: &str, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT revision, ciphertext, source, created_at + FROM vault_object_history + WHERE object_id = $1 + ORDER BY revision DESC + "#, + ) + .bind(entry_id) + .fetch_all(&vault.pool) + .await + .context("failed to load local history")?; + + let mut history = Vec::new(); + for row in rows { + let revision: i64 = row.try_get("revision")?; + let ciphertext: Vec = row.try_get("ciphertext")?; + let source: String = row.try_get("source")?; + let created_at: String = row.try_get("created_at")?; + let view = decrypt_view(vault, &ciphertext)?; + if let Some(value) = secret_value_from_view(&view, secret_name) { + history.push(LocalHistoryItem { + history_id: revision, + secret_id: format!("{}:{secret_name}", view.id), + name: secret_name.to_string(), + secret_type: infer_secret_type(secret_name).to_string(), + masked_value: mask_secret(&value), + value, + version: revision, + action: source, + created_at, + }); + } + } + Ok(history) +} + +pub async fn create_entry(vault: &LocalVault, draft: LocalEntryDraft) -> Result { + let id = Uuid::new_v4(); + let view = draft_to_view(id, draft); + write_view(vault, &view, Some("create")).await?; + entry_detail(vault, &id.to_string()).await +} + +pub async fn update_entry( + vault: &LocalVault, + detail: LocalEntryDetail, +) -> Result { + let existing = load_view(vault, &detail.id).await?; + let updated = LocalEntryDraft { + folder: detail.folder.clone(), + name: detail.name.clone(), + cipher_type: detail.cipher_type.clone(), + metadata: detail.metadata.clone(), + secrets: existing + .payload_secret_names() + .into_iter() + .filter_map(|name| { + let secret_type = infer_secret_type(&name).to_string(); + secret_value_from_view(&existing, &name).map(|value| LocalSecretDraft { + name, + secret_type: Some(secret_type), + value, + }) + }) + .collect(), + }; + let mut view = draft_to_view(existing.id, updated); + view.deleted_at = existing.deleted_at; + write_view(vault, &view, Some("update")).await?; + entry_detail(vault, &view.id.to_string()).await +} + +pub async fn delete_entry(vault: &LocalVault, entry_id: &str) -> Result<()> { + let mut view = load_view(vault, entry_id).await?; + view.deleted_at = Some(Utc::now()); + write_view(vault, &view, Some("delete")).await?; + Ok(()) +} + +pub async fn restore_entry(vault: &LocalVault, entry_id: &str) -> Result<()> { + let mut view = load_view(vault, entry_id).await?; + view.deleted_at = None; + write_view(vault, &view, Some("restore")).await?; + Ok(()) +} + +pub async fn create_secret( + vault: &LocalVault, + entry_id: &str, + secret: LocalSecretDraft, +) -> Result { + let mut view = load_view(vault, entry_id).await?; + set_secret_on_view( + &mut view, + &secret.name, + secret.value, + secret.secret_type.as_deref(), + ); + write_view(vault, &view, Some("create_secret")).await?; + entry_detail(vault, entry_id).await +} + +pub async fn update_secret( + vault: &LocalVault, + update: LocalSecretUpdateDraft, +) -> Result { + let (entry_id, secret_name) = split_secret_ref(&update.id)?; + let mut view = load_view(vault, &entry_id.to_string()).await?; + let current_name = update.name.clone().unwrap_or(secret_name.clone()); + let current_value = match update.value { + Some(value) => value, + None => secret_value_from_view(&view, &secret_name).unwrap_or_default(), + }; + set_secret_on_view( + &mut view, + ¤t_name, + current_value, + update.secret_type.as_deref(), + ); + write_view(vault, &view, Some("update_secret")).await?; + entry_detail(vault, &entry_id.to_string()).await +} + +pub async fn delete_secret(vault: &LocalVault, secret_id: &str) -> Result<()> { + let (entry_id, secret_name) = split_secret_ref(secret_id)?; + let mut view = load_view(vault, &entry_id.to_string()).await?; + remove_secret_from_view(&mut view, &secret_name); + write_view(vault, &view, Some("delete_secret")).await?; + Ok(()) +} + +pub async fn rollback_secret( + vault: &LocalVault, + secret_id: &str, + history_id: Option, +) -> Result { + let (entry_id, secret_name) = split_secret_ref(secret_id)?; + let revision = history_id.context("history_id is required for local rollback")?; + let row = sqlx::query( + r#" + SELECT ciphertext + FROM vault_object_history + WHERE object_id = $1 AND revision = $2 + "#, + ) + .bind(entry_id) + .bind(revision) + .fetch_one(&vault.pool) + .await + .context("failed to load local rollback revision")?; + let ciphertext: Vec = row.try_get("ciphertext")?; + let snapshot = decrypt_view(vault, &ciphertext)?; + let value = secret_value_from_view(&snapshot, &secret_name) + .context("secret not found in rollback snapshot")?; + let mut current = load_view(vault, &entry_id.to_string()).await?; + set_secret_on_view(&mut current, &secret_name, value, None); + write_view(vault, ¤t, Some("rollback_secret")).await?; + entry_detail(vault, &entry_id.to_string()).await +} + +pub async fn sync_pull(vault: &LocalVault, api_base: &str, token: &str) -> Result<()> { + let cursor = local_server_revision(vault).await?; + let client = reqwest::Client::new(); + let response = client + .post(format!("{api_base}/sync/pull")) + .bearer_auth(token) + .json(&serde_json::json!({ + "cursor": cursor, + "limit": 200, + "includeDeleted": true + })) + .send() + .await + .context("failed to pull sync objects")? + .error_for_status() + .context("sync pull returned error")?; + let payload: secrets_domain::SyncPullResponse = response + .json() + .await + .context("failed to decode sync pull response")?; + + for object in payload.objects { + apply_remote_object(vault, &object).await?; + } + set_local_server_revision(vault, payload.server_revision).await?; + Ok(()) +} + +pub async fn sync_push(vault: &LocalVault, api_base: &str, token: &str) -> Result<()> { + let pending_rows = sqlx::query( + r#" + SELECT change_id, object_id, object_kind, operation, base_revision, ciphertext, content_hash + FROM pending_changes + ORDER BY queued_at ASC + "#, + ) + .fetch_all(&vault.pool) + .await + .context("failed to load pending changes")?; + + if pending_rows.is_empty() { + return Ok(()); + } + + let changes = pending_rows + .iter() + .map(|row| -> Result { + Ok(VaultObjectChange { + change_id: Uuid::parse_str(&row.try_get::("change_id")?)?, + object_id: Uuid::parse_str(&row.try_get::("object_id")?)?, + object_kind: VaultObjectKind::Cipher, + operation: row.try_get("operation")?, + base_revision: row.try_get("base_revision")?, + cipher_version: Some(1), + ciphertext: row.try_get("ciphertext")?, + content_hash: row.try_get("content_hash")?, + }) + }) + .collect::>>()?; + + let client = reqwest::Client::new(); + let response = client + .post(format!("{api_base}/sync/push")) + .bearer_auth(token) + .json(&serde_json::json!({ "changes": changes })) + .send() + .await + .context("failed to push local changes")? + .error_for_status() + .context("sync push returned error")?; + let payload: secrets_domain::SyncPushResponse = response + .json() + .await + .context("failed to decode sync push response")?; + + for accepted in payload.accepted { + sqlx::query("DELETE FROM pending_changes WHERE change_id = $1") + .bind(accepted.change_id.to_string()) + .execute(&vault.pool) + .await + .context("failed to clear accepted pending change")?; + } + set_local_server_revision(vault, payload.server_revision).await?; + Ok(()) +} + +pub async fn queue_sync_from_view( + vault: &LocalVault, + view: &CipherView, + operation: &str, +) -> Result<()> { + let ciphertext = encrypt_view(vault, view)?; + let content_hash = hash_ciphertext(&ciphertext); + let base_revision = current_revision(vault, &view.id).await.ok(); + sqlx::query( + r#" + INSERT INTO pending_changes ( + change_id, object_id, object_kind, operation, base_revision, ciphertext, content_hash, queued_at + ) + VALUES ($1, $2, 'cipher', $3, $4, $5, $6, $7) + "#, + ) + .bind(Uuid::new_v4().to_string()) + .bind(view.id.to_string()) + .bind(operation) + .bind(base_revision) + .bind(ciphertext) + .bind(content_hash) + .bind(Utc::now().to_rfc3339()) + .execute(&vault.pool) + .await + .context("failed to queue pending change")?; + Ok(()) +} + +fn set_unlocked_key(vault: &LocalVault, key: Option<[u8; 32]>) { + if let Ok(mut guard) = vault.unlocked_key.write() { + *guard = key; + } +} + +fn unlocked_key(vault: &LocalVault) -> Result<[u8; 32]> { + vault + .unlocked_key + .read() + .map_err(|_| anyhow!("failed to read unlock state"))? + .ok_or_else(|| anyhow!("vault is locked")) +} + +fn derive_master_key(password: &str, salt: &[u8], config: &KdfConfig) -> Result<[u8; 32]> { + let argon2 = config.build_argon2()?; + let mut out = [0_u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut out) + .map_err(|err| anyhow!("failed to derive master key: {err}"))?; + Ok(out) +} + +fn derive_random_vault_key() -> Vec { + Uuid::new_v4() + .as_bytes() + .iter() + .copied() + .chain(Uuid::new_v4().as_bytes().iter().copied()) + .collect() +} + +async fn upsert_meta(pool: &SqlitePool, key: &str, value: Vec) -> Result<()> { + sqlx::query( + r#" + INSERT INTO vault_meta (key, value) + VALUES ($1, $2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + "#, + ) + .bind(key) + .bind(value) + .execute(pool) + .await + .with_context(|| format!("failed to upsert meta {key}"))?; + Ok(()) +} + +async fn get_meta(pool: &SqlitePool, key: &str) -> Result>> { + let row = sqlx::query("SELECT value FROM vault_meta WHERE key = $1") + .bind(key) + .fetch_optional(pool) + .await + .with_context(|| format!("failed to load meta {key}"))?; + Ok(row.map(|row| row.get::, _>("value"))) +} + +async fn local_server_revision(vault: &LocalVault) -> Result { + let row = sqlx::query("SELECT last_server_revision FROM sync_state WHERE scope = 'default'") + .fetch_optional(&vault.pool) + .await + .context("failed to read sync state")?; + Ok(row + .map(|row| row.get::("last_server_revision")) + .unwrap_or(0)) +} + +async fn set_local_server_revision(vault: &LocalVault, revision: i64) -> Result<()> { + sqlx::query( + r#" + INSERT INTO sync_state (scope, last_server_revision, last_success_at) + VALUES ('default', $1, $2) + ON CONFLICT(scope) DO UPDATE SET + last_server_revision = excluded.last_server_revision, + last_success_at = excluded.last_success_at + "#, + ) + .bind(revision) + .bind(Utc::now().to_rfc3339()) + .execute(&vault.pool) + .await + .context("failed to update local sync state")?; + Ok(()) +} + +async fn apply_remote_object(vault: &LocalVault, object: &VaultObjectEnvelope) -> Result<()> { + sqlx::query( + r#" + INSERT INTO vault_objects ( + object_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, last_synced_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT(object_id) DO UPDATE SET + object_kind = excluded.object_kind, + revision = excluded.revision, + cipher_version = excluded.cipher_version, + ciphertext = excluded.ciphertext, + content_hash = excluded.content_hash, + deleted_at = excluded.deleted_at, + updated_at = excluded.updated_at, + last_synced_at = excluded.last_synced_at + "#, + ) + .bind(object.object_id.to_string()) + .bind(object.object_kind.as_str()) + .bind(object.revision) + .bind(object.cipher_version) + .bind(object.ciphertext.clone()) + .bind(&object.content_hash) + .bind(object.deleted_at.map(|value| value.to_rfc3339())) + .bind(object.updated_at.to_rfc3339()) + .bind(Utc::now().to_rfc3339()) + .execute(&vault.pool) + .await + .context("failed to apply remote object")?; + + sqlx::query( + r#" + INSERT OR IGNORE INTO vault_object_history (object_id, revision, ciphertext, source, created_at) + VALUES ($1, $2, $3, 'sync_pull', $4) + "#, + ) + .bind(object.object_id.to_string()) + .bind(object.revision) + .bind(object.ciphertext.clone()) + .bind(Utc::now().to_rfc3339()) + .execute(&vault.pool) + .await + .context("failed to append local object history")?; + Ok(()) +} + +async fn load_all_views(vault: &LocalVault) -> Result> { + let rows = sqlx::query("SELECT ciphertext FROM vault_objects ORDER BY updated_at DESC") + .fetch_all(&vault.pool) + .await + .context("failed to load local vault objects")?; + rows.into_iter() + .map(|row| row.get::, _>("ciphertext")) + .map(|ciphertext| decrypt_view(vault, &ciphertext)) + .collect() +} + +async fn load_view(vault: &LocalVault, entry_id: &str) -> Result { + let row = sqlx::query("SELECT ciphertext FROM vault_objects WHERE object_id = $1") + .bind(entry_id) + .fetch_one(&vault.pool) + .await + .with_context(|| format!("failed to load local entry {entry_id}"))?; + let ciphertext: Vec = row.get("ciphertext"); + decrypt_view(vault, &ciphertext) +} + +fn encrypt_view(vault: &LocalVault, view: &CipherView) -> Result> { + let key = unlocked_key(vault)?; + let plaintext = serde_json::to_vec(view).context("failed to encode cipher view")?; + encrypt(&key, &plaintext) +} + +fn decrypt_view(vault: &LocalVault, ciphertext: &[u8]) -> Result { + let key = unlocked_key(vault)?; + let plaintext = decrypt(&key, ciphertext).context("failed to decrypt local vault object")?; + serde_json::from_slice(&plaintext).context("failed to decode cipher view") +} + +async fn write_view(vault: &LocalVault, view: &CipherView, source: Option<&str>) -> Result<()> { + let ciphertext = encrypt_view(vault, view)?; + let content_hash = hash_ciphertext(&ciphertext); + let next_revision = current_revision(vault, &view.id).await.unwrap_or(0) + 1; + let updated_at = Utc::now().to_rfc3339(); + sqlx::query( + r#" + INSERT INTO vault_objects ( + object_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, last_synced_at + ) + VALUES ($1, 'cipher', $2, 1, $3, $4, $5, $6, NULL) + ON CONFLICT(object_id) DO UPDATE SET + revision = excluded.revision, + cipher_version = excluded.cipher_version, + ciphertext = excluded.ciphertext, + content_hash = excluded.content_hash, + deleted_at = excluded.deleted_at, + updated_at = excluded.updated_at + "#, + ) + .bind(view.id.to_string()) + .bind(next_revision) + .bind(ciphertext.clone()) + .bind(content_hash.clone()) + .bind(view.deleted_at.map(|value| value.to_rfc3339())) + .bind(updated_at.clone()) + .execute(&vault.pool) + .await + .context("failed to write local vault object")?; + + sqlx::query( + r#" + INSERT INTO vault_object_history (object_id, revision, ciphertext, source, created_at) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(view.id.to_string()) + .bind(next_revision) + .bind(ciphertext) + .bind(source.unwrap_or("local")) + .bind(updated_at) + .execute(&vault.pool) + .await + .context("failed to append local object history")?; + + queue_sync_from_view( + vault, + view, + if view.deleted_at.is_some() { + "delete" + } else { + "upsert" + }, + ) + .await?; + Ok(()) +} + +async fn current_revision(vault: &LocalVault, object_id: &Uuid) -> Result { + let row = sqlx::query("SELECT revision FROM vault_objects WHERE object_id = $1") + .bind(object_id.to_string()) + .fetch_optional(&vault.pool) + .await + .context("failed to load current revision")?; + Ok(row.map(|row| row.get::("revision")).unwrap_or(0)) +} + +fn hash_ciphertext(ciphertext: &[u8]) -> String { + let digest = Sha256::digest(ciphertext); + format!("sha256:{}", hex::encode(digest)) +} + +fn draft_to_view(id: Uuid, draft: LocalEntryDraft) -> CipherView { + let mut custom_fields = Vec::new(); + let mut notes = None; + for field in &draft.metadata { + if field.label.contains("说明") || field.label.eq_ignore_ascii_case("notes") { + if !field.value.trim().is_empty() { + notes = Some(field.value.clone()); + } + } else if !field.label.trim().is_empty() { + custom_fields.push(CustomField { + name: field.label.clone(), + value: Value::String(field.value.clone()), + sensitive: false, + }); + } + } + + let cipher_type = infer_cipher_type(&draft.cipher_type, &draft.metadata, &draft.secrets); + let payload = payload_from_draft(cipher_type, &draft.metadata, &draft.secrets); + + CipherView { + id, + cipher_type, + name: draft.name, + folder: draft.folder, + notes, + custom_fields, + deleted_at: None, + revision_date: Utc::now(), + payload, + } +} + +fn infer_cipher_type( + entry_type: &str, + metadata: &[LocalDetailField], + secrets: &[LocalSecretDraft], +) -> CipherType { + let has_password = secrets + .iter() + .any(|secret| secret.name.eq_ignore_ascii_case("password")); + let has_token = secrets.iter().any(|secret| { + matches!( + secret.name.to_ascii_lowercase().as_str(), + "token" | "api_key" | "access_token" | "access_key" + ) + }); + let has_ssh = secrets + .iter() + .any(|secret| secret.name.eq_ignore_ascii_case("ssh_key")); + let has_username = metadata.iter().any(|field| { + matches!( + field.label.to_ascii_lowercase().as_str(), + "username" | "user" | "ssh_user" + ) + }); + let has_url = metadata.iter().any(|field| { + matches!( + field.label.to_ascii_lowercase().as_str(), + "url" | "base_url" | "endpoint" | "host" | "hostname" + ) + }); + + if has_ssh { + CipherType::SshKey + } else if has_token && has_url { + CipherType::ApiKey + } else if entry_type == "account" || (has_username && has_password) { + CipherType::Login + } else { + CipherType::SecureNote + } +} + +fn payload_from_draft( + cipher_type: CipherType, + metadata: &[LocalDetailField], + secrets: &[LocalSecretDraft], +) -> ItemPayload { + match cipher_type { + CipherType::Login => ItemPayload::Login(LoginPayload { + username: metadata_lookup(metadata, &["username", "user"]), + uris: metadata_lookup(metadata, &["url", "base_url", "endpoint"]) + .into_iter() + .collect(), + password: secret_lookup(secrets, &["password"]), + totp: secret_lookup(secrets, &["totp"]), + }), + CipherType::ApiKey => ItemPayload::ApiKey(ApiKeyPayload { + client_id: metadata_lookup(metadata, &["username", "user"]), + secret: secret_lookup(secrets, &["token", "api_key", "access_token", "access_key"]), + base_url: metadata_lookup(metadata, &["base_url", "url", "endpoint"]), + host: metadata_lookup(metadata, &["host", "hostname"]), + }), + CipherType::SshKey => ItemPayload::SshKey(SshKeyPayload { + username: metadata_lookup(metadata, &["ssh_user", "user", "username"]), + host: metadata_lookup(metadata, &["host", "hostname", "public_ip", "private_ip"]), + port: metadata_lookup(metadata, &["ssh_port", "port"]) + .and_then(|value| value.parse().ok()), + private_key: secret_lookup(secrets, &["ssh_key"]), + passphrase: secret_lookup(secrets, &["password", "passphrase"]), + }), + _ => ItemPayload::SecureNote(SecureNotePayload { + text: metadata_lookup(metadata, &["notes", "说明"]), + }), + } +} + +fn metadata_lookup(metadata: &[LocalDetailField], keys: &[&str]) -> Option { + metadata.iter().find_map(|field| { + if keys.iter().any(|key| field.label.eq_ignore_ascii_case(key)) { + Some(field.value.clone()) + } else { + None + } + }) +} + +fn secret_lookup(secrets: &[LocalSecretDraft], keys: &[&str]) -> Option { + secrets.iter().find_map(|secret| { + if keys.iter().any(|key| secret.name.eq_ignore_ascii_case(key)) { + Some(secret.value.clone()) + } else { + None + } + }) +} + +fn detail_parts(view: &CipherView) -> (Vec, Vec) { + let mut metadata = Vec::new(); + if let Some(notes) = view.notes.as_ref() { + metadata.push(LocalDetailField { + label: "notes".to_string(), + value: notes.clone(), + }); + } + for field in &view.custom_fields { + metadata.push(LocalDetailField { + label: field.name.clone(), + value: stringify_value(&field.value), + }); + } + + let secrets = payload_secret_fields(view); + (metadata, secrets) +} + +fn payload_secret_fields(view: &CipherView) -> Vec { + let names = view.payload_secret_names(); + names + .into_iter() + .filter_map(|name| { + secret_value_from_view(view, &name).map(|value| LocalSecretField { + id: format!("{}:{name}", view.id), + name: name.clone(), + secret_type: infer_secret_type(&name).to_string(), + masked_value: mask_secret(&value), + version: 1, + }) + }) + .collect() +} + +trait CipherViewSecrets { + fn payload_secret_names(&self) -> Vec; +} + +impl CipherViewSecrets for CipherView { + fn payload_secret_names(&self) -> Vec { + match &self.payload { + ItemPayload::Login(payload) => { + let mut out = Vec::new(); + if payload.password.is_some() { + out.push("password".to_string()); + } + if payload.totp.is_some() { + out.push("totp".to_string()); + } + out + } + ItemPayload::ApiKey(payload) => { + if payload.secret.is_some() { + vec!["api_key".to_string()] + } else { + Vec::new() + } + } + ItemPayload::SshKey(payload) => { + let mut out = Vec::new(); + if payload.private_key.is_some() { + out.push("ssh_key".to_string()); + } + if payload.passphrase.is_some() { + out.push("password".to_string()); + } + out + } + ItemPayload::SecureNote(_) => Vec::new(), + } + } +} + +fn secret_value_from_view(view: &CipherView, secret_name: &str) -> Option { + match &view.payload { + ItemPayload::Login(payload) => match secret_name { + "password" => payload.password.clone(), + "totp" => payload.totp.clone(), + _ => None, + }, + ItemPayload::ApiKey(payload) => { + if matches!( + secret_name, + "api_key" | "token" | "access_key" | "access_token" + ) { + payload.secret.clone() + } else { + None + } + } + ItemPayload::SshKey(payload) => match secret_name { + "ssh_key" => payload.private_key.clone(), + "password" | "passphrase" => payload.passphrase.clone(), + _ => None, + }, + ItemPayload::SecureNote(_) => None, + } +} + +fn set_secret_on_view( + view: &mut CipherView, + secret_name: &str, + value: String, + secret_type: Option<&str>, +) { + match &mut view.payload { + ItemPayload::Login(payload) => match secret_name { + "password" => payload.password = Some(value), + "totp" => payload.totp = Some(value), + _ => upsert_sensitive_field(&mut view.custom_fields, secret_name, value), + }, + ItemPayload::ApiKey(payload) => { + if matches!( + secret_name, + "api_key" | "token" | "access_key" | "access_token" + ) { + payload.secret = Some(value); + } else { + upsert_sensitive_field(&mut view.custom_fields, secret_name, value); + } + } + ItemPayload::SshKey(payload) => match secret_name { + "ssh_key" => payload.private_key = Some(value), + "password" | "passphrase" => payload.passphrase = Some(value), + _ => upsert_sensitive_field(&mut view.custom_fields, secret_name, value), + }, + ItemPayload::SecureNote(_) => { + let _ = secret_type; + upsert_sensitive_field(&mut view.custom_fields, secret_name, value); + } + } + view.revision_date = Utc::now(); +} + +fn remove_secret_from_view(view: &mut CipherView, secret_name: &str) { + match &mut view.payload { + ItemPayload::Login(payload) => match secret_name { + "password" => payload.password = None, + "totp" => payload.totp = None, + _ => remove_custom_field(&mut view.custom_fields, secret_name), + }, + ItemPayload::ApiKey(payload) => { + if matches!( + secret_name, + "api_key" | "token" | "access_key" | "access_token" + ) { + payload.secret = None; + } else { + remove_custom_field(&mut view.custom_fields, secret_name); + } + } + ItemPayload::SshKey(payload) => match secret_name { + "ssh_key" => payload.private_key = None, + "password" | "passphrase" => payload.passphrase = None, + _ => remove_custom_field(&mut view.custom_fields, secret_name), + }, + ItemPayload::SecureNote(_) => remove_custom_field(&mut view.custom_fields, secret_name), + } + view.revision_date = Utc::now(); +} + +fn upsert_sensitive_field(fields: &mut Vec, name: &str, value: String) { + if let Some(field) = fields.iter_mut().find(|field| field.name == name) { + field.value = Value::String(value); + field.sensitive = true; + return; + } + fields.push(CustomField { + name: name.to_string(), + value: Value::String(value), + sensitive: true, + }); +} + +fn remove_custom_field(fields: &mut Vec, name: &str) { + fields.retain(|field| field.name != name); +} + +fn stringify_value(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(text) => text.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + other => other.to_string(), + } +} + +fn infer_secret_type(secret_name: &str) -> &'static str { + match secret_name { + "password" | "passphrase" => "password", + "ssh_key" => "key", + _ => "text", + } +} + +fn mask_secret(value: &str) -> String { + if value.is_empty() { + return "未设置".to_string(); + } + if value.len() <= 4 { + return "••••".to_string(); + } + format!("{}••••••", &value[..2]) +} + +fn split_secret_ref(secret_id: &str) -> Result<(Uuid, String)> { + let (entry_id, name) = secret_id + .split_once(':') + .context("invalid local secret id")?; + Ok((Uuid::parse_str(entry_id)?, name.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + use std::time::{SystemTime, UNIX_EPOCH}; + + async fn test_vault() -> LocalVault { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + migrate_local_vault(&pool) + .await + .expect("migrate local vault"); + LocalVault { + pool, + unlocked_key: Arc::new(RwLock::new(None)), + } + } + + fn temp_vault_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + std::env::temp_dir().join(format!("secrets-{name}-{stamp}.sqlite3")) + } + + fn field(label: &str, value: &str) -> LocalDetailField { + LocalDetailField { + label: label.to_string(), + value: value.to_string(), + } + } + + fn secret(name: &str, value: &str) -> LocalSecretDraft { + LocalSecretDraft { + name: name.to_string(), + secret_type: None, + value: value.to_string(), + } + } + + #[test] + fn infer_cipher_type_prefers_ssh_key() { + let metadata = vec![ + field("ssh_user", "deploy"), + field("host", "git.example.com"), + ]; + let secrets = vec![secret("ssh_key", "pem"), secret("password", "passphrase")]; + + let ty = infer_cipher_type("service", &metadata, &secrets); + + assert_eq!(ty, CipherType::SshKey); + } + + #[test] + fn infer_cipher_type_detects_api_key_shape() { + let metadata = vec![field("base_url", "https://api.example.com")]; + let secrets = vec![secret("api_key", "ak-123")]; + + let ty = infer_cipher_type("service", &metadata, &secrets); + + assert_eq!(ty, CipherType::ApiKey); + } + + #[test] + fn infer_cipher_type_detects_login_shape() { + let metadata = vec![ + field("username", "alice"), + field("url", "https://mail.example.com"), + ]; + let secrets = vec![secret("password", "pw-123")]; + + let ty = infer_cipher_type("account", &metadata, &secrets); + + assert_eq!(ty, CipherType::Login); + } + + #[test] + fn infer_cipher_type_defaults_to_secure_note() { + let metadata = vec![field("notes", "free form note")]; + let secrets = vec![secret("misc", "value")]; + + let ty = infer_cipher_type("misc", &metadata, &secrets); + + assert_eq!(ty, CipherType::SecureNote); + } + + #[test] + fn payload_from_draft_builds_api_key_fields() { + let metadata = vec![ + field("base_url", "https://api.example.com"), + field("host", "api.example.com"), + field("username", "client-1"), + ]; + let secrets = vec![secret("access_token", "tok-123")]; + + let payload = payload_from_draft(CipherType::ApiKey, &metadata, &secrets); + + match payload { + ItemPayload::ApiKey(api_key) => { + assert_eq!(api_key.client_id.as_deref(), Some("client-1")); + assert_eq!(api_key.secret.as_deref(), Some("tok-123")); + assert_eq!(api_key.base_url.as_deref(), Some("https://api.example.com")); + assert_eq!(api_key.host.as_deref(), Some("api.example.com")); + } + other => panic!("expected api key payload, got {other:?}"), + } + } + + #[tokio::test] + async fn setup_master_password_marks_vault_initialized_and_unlocked() { + let vault = test_vault().await; + + let before = bootstrap(&vault).await.expect("bootstrap before setup"); + assert!(!before.has_master_password); + assert!(!before.unlocked); + + setup_master_password( + &vault, + "correct horse battery staple", + &KdfConfig::default(), + ) + .await + .expect("setup master password"); + + let after = bootstrap(&vault).await.expect("bootstrap after setup"); + assert!(after.has_master_password); + assert!(after.unlocked); + } + + #[tokio::test] + async fn lock_and_unlock_cycle_requires_correct_password() { + let vault = test_vault().await; + let password = "correct horse battery staple"; + + setup_master_password(&vault, password, &KdfConfig::default()) + .await + .expect("setup master password"); + + lock(&vault); + let locked = bootstrap(&vault).await.expect("bootstrap after lock"); + assert!(locked.has_master_password); + assert!(!locked.unlocked); + + let wrong = unlock(&vault, "wrong-password").await; + assert!(wrong.is_err()); + + let still_locked = bootstrap(&vault) + .await + .expect("bootstrap after wrong unlock"); + assert!(!still_locked.unlocked); + + unlock(&vault, password) + .await + .expect("unlock with correct password"); + let unlocked = bootstrap(&vault) + .await + .expect("bootstrap after correct unlock"); + assert!(unlocked.unlocked); + } + + #[tokio::test] + async fn open_or_create_local_vault_creates_missing_file() { + let path = temp_vault_path("local-vault-create"); + if path.exists() { + std::fs::remove_file(&path).expect("remove stale temp vault"); + } + + let vault = open_or_create_local_vault_at(&path) + .await + .expect("create local vault at missing path"); + + assert!(path.exists()); + let state = bootstrap(&vault).await.expect("bootstrap temp vault"); + assert!(!state.has_master_password); + assert!(!state.unlocked); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..77b9c98 --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,1179 @@ +mod local_vault; +mod session_api; + +use anyhow::{Context, Result as AnyResult, anyhow}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use local_vault::{ + LocalDetailField, LocalEntryDetail, LocalEntryDraft, LocalEntryQuery, LocalHistoryItem, + LocalSecretDraft, LocalSecretUpdateDraft, LocalSecretValue, LocalVault, + bootstrap as vault_bootstrap, create_entry as vault_create_entry, + create_secret as vault_create_secret, delete_entry as vault_delete_entry, + delete_secret as vault_delete_secret, entry_detail as vault_entry_detail, + list_entries as vault_list_entries, lock as lock_local_vault, open_or_create_local_vault, + restore_entry as vault_restore_entry, reveal_secret_value as vault_reveal_secret_value, + rollback_secret as vault_rollback_secret, secret_history as vault_secret_history, + setup_master_password as vault_setup_master_password, sync_pull as vault_sync_pull, + sync_push as vault_sync_push, unlock as unlock_local_vault, update_entry as vault_update_entry, + update_secret as vault_update_secret, +}; +use reqwest::Client; +use secrets_client_integrations::{ + ClaudeCodeAdapter, ClientAdapter, CursorAdapter, has_managed_server, upsert_managed_server, +}; +use secrets_device_auth::new_device_fingerprint; +use secrets_domain::KdfConfig; +use serde::{Deserialize, Serialize}; +use session_api::start_desktop_session_server; +use sha2::{Digest, Sha256}; +use std::{ + fs, + path::PathBuf, + process::Command, + sync::{Arc, RwLock}, +}; +use tauri::Manager; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; +use url::Url; + +#[derive(Clone)] +struct DesktopState { + api_base: String, + session_bind: String, + client: Client, + device_token: Arc>>, + local_vault: LocalVault, +} + +#[derive(Serialize, Deserialize)] +struct AppBootstrap { + logged_in: bool, + shell: Option, + vault: Option, +} + +#[derive(Serialize, Deserialize)] +struct ShellData { + user: UserProfile, + folders: Vec, + entry_types: Vec, + entries: Vec, + selected_entry_id: Option, + selected_entry: Option, +} + +#[derive(Serialize, Deserialize)] +struct VaultStatus { + unlocked: bool, + has_master_password: bool, +} + +#[derive(Serialize, Deserialize)] +struct UserProfile { + id: String, + name: String, + email: String, +} + +#[derive(Serialize, Deserialize)] +struct FolderItem { + id: String, + label: String, + count: i64, + kind: String, +} + +#[derive(Serialize, Deserialize)] +struct EntryListItem { + id: String, + title: String, + subtitle: String, + folder: String, + deleted: bool, +} + +#[derive(Serialize, Deserialize)] +struct EntryDetail { + id: String, + title: String, + folder: String, + entry_type: String, + metadata: Vec, + secrets: Vec, + deleted: bool, +} + +#[derive(Serialize, Deserialize)] +struct DetailField { + label: String, + value: String, +} + +#[derive(Serialize, Deserialize)] +struct SecretField { + id: String, + name: String, + secret_type: String, + masked_value: String, + version: i64, +} + +#[derive(Serialize, Deserialize, Clone)] +struct SecretValue { + id: String, + entry_id: String, + name: String, + secret_type: String, + value: String, + version: i64, +} + +#[derive(Serialize, Deserialize, Clone)] +struct SecretHistoryItem { + history_id: i64, + secret_id: String, + name: String, + secret_type: String, + masked_value: String, + value: String, + version: i64, + action: String, + created_at: String, +} + +#[derive(Serialize, Deserialize, Clone)] +struct SecretDraft { + name: String, + secret_type: Option, + value: String, +} + +#[derive(Serialize, Deserialize)] +struct EntryDraft { + folder: String, + title: String, + entry_type: String, + metadata: Vec, + secrets: Vec, +} + +#[derive(Serialize, Deserialize)] +struct SecretUpdateDraft { + id: String, + name: Option, + secret_type: Option, + value: Option, +} + +#[derive(Serialize, Deserialize)] +struct DeviceInfo { + name: String, + platform: String, + client_version: String, + last_seen: String, + ip: Option, +} + +#[derive(Serialize, Clone)] +struct IntegrationStatus { + app_name: String, + configured: bool, + config_path: String, +} + +#[derive(Serialize, Clone)] +struct McpDialogData { + integrations: Vec, + mcp_json: String, +} + +#[derive(Serialize)] +struct ApplyConfigResponse { + target: String, + data: McpDialogData, +} + +#[derive(Deserialize)] +struct DemoLoginResponse { + device_token: String, +} + +#[derive(Deserialize)] +struct GoogleDesktopClientFile { + installed: GoogleDesktopClient, +} + +#[derive(Deserialize)] +struct GoogleDesktopClient { + client_id: String, + client_secret: Option, + auth_uri: String, + token_uri: String, +} + +#[derive(Deserialize)] +struct GoogleTokenResponse { + access_token: String, +} + +#[derive(Serialize)] +struct GoogleDesktopLoginRequest { + access_token: String, + device_name: String, + platform: String, + client_version: String, + device_fingerprint: String, +} + +#[derive(Serialize, Deserialize)] +struct EntryListQuery { + folder: Option, + entry_type: Option, + query: Option, + deleted_only: bool, +} + +#[tauri::command] +async fn app_bootstrap( + window: tauri::Window, + state: tauri::State<'_, DesktopState>, +) -> Result { + let result = bootstrap_from_disk(&state) + .await + .map_err(|err| err.to_string())?; + + if result.logged_in { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 1180.0, + height: 820.0, + })); + let _ = window.center(); + } else { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 420.0, + height: 400.0, + })); + let _ = window.center(); + } + + Ok(result) +} + +#[tauri::command] +async fn vault_status(state: tauri::State<'_, DesktopState>) -> Result { + let status = vault_bootstrap(&state.local_vault) + .await + .map_err(|err| err.to_string())?; + Ok(VaultStatus { + unlocked: status.unlocked, + has_master_password: status.has_master_password, + }) +} + +#[tauri::command] +async fn setup_master_password( + state: tauri::State<'_, DesktopState>, + password: String, +) -> Result { + vault_setup_master_password(&state.local_vault, &password, &KdfConfig::default()) + .await + .map_err(|err| err.to_string())?; + let status = vault_bootstrap(&state.local_vault) + .await + .map_err(|err| err.to_string())?; + Ok(VaultStatus { + unlocked: status.unlocked, + has_master_password: status.has_master_password, + }) +} + +#[tauri::command] +async fn unlock_vault( + state: tauri::State<'_, DesktopState>, + password: String, +) -> Result { + unlock_local_vault(&state.local_vault, &password) + .await + .map_err(|err| err.to_string())?; + let status = vault_bootstrap(&state.local_vault) + .await + .map_err(|err| err.to_string())?; + Ok(VaultStatus { + unlocked: status.unlocked, + has_master_password: status.has_master_password, + }) +} + +#[tauri::command] +fn lock_vault(state: tauri::State<'_, DesktopState>) -> Result { + lock_local_vault(&state.local_vault); + Ok(VaultStatus { + unlocked: false, + has_master_password: true, + }) +} + +#[tauri::command] +async fn continue_demo_login( + window: tauri::Window, + state: tauri::State<'_, DesktopState>, +) -> Result { + let google_client = load_google_desktop_client().map_err(|err| err.to_string())?; + let payload = complete_google_desktop_login(&state, &google_client) + .await + .map_err(|err| err.to_string())?; + + set_device_token(&state, Some(payload.device_token)); + let _ = sync_local_vault(&state).await; + let _ = apply_mcp_config_to_all(); + let result = bootstrap_from_disk(&state) + .await + .map_err(|err| err.to_string())?; + + if result.logged_in { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 1180.0, + height: 820.0, + })); + let _ = window.center(); + } + + Ok(result) +} + +#[tauri::command] +fn logout(window: tauri::Window) -> Result { + set_device_token(&window.state::(), None); + lock_local_vault(&window.state::().local_vault); + + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 420.0, + height: 400.0, + })); + let _ = window.center(); + + Ok(AppBootstrap { + logged_in: false, + shell: None, + vault: Some(VaultStatus { + unlocked: false, + has_master_password: false, + }), + }) +} + +#[tauri::command] +async fn device_list(state: tauri::State<'_, DesktopState>) -> Result, String> { + let response = authorized_get(&state, "/devices") + .await + .map_err(|err| err.to_string())?; + response + .json::>() + .await + .context("failed to decode device list") + .map_err(|err| err.to_string()) +} + +#[tauri::command] +async fn entry_detail( + state: tauri::State<'_, DesktopState>, + entry_id: String, +) -> Result { + let detail = vault_entry_detail(&state.local_vault, &entry_id) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_entry_detail(detail)) +} + +#[tauri::command] +async fn list_entries( + state: tauri::State<'_, DesktopState>, + query: EntryListQuery, +) -> Result, String> { + let entries = vault_list_entries( + &state.local_vault, + &LocalEntryQuery { + folder: query.folder, + cipher_type: query.entry_type, + query: query.query, + deleted_only: query.deleted_only, + }, + ) + .await + .map_err(|err| err.to_string())?; + Ok(entries + .into_iter() + .map(|entry| EntryListItem { + id: entry.id, + title: entry.name, + subtitle: entry.cipher_type, + folder: entry.folder, + deleted: entry.deleted, + }) + .collect()) +} + +#[tauri::command] +async fn update_entry_detail( + state: tauri::State<'_, DesktopState>, + entry: EntryDetail, +) -> Result { + let updated = vault_update_entry(&state.local_vault, map_entry_detail_to_local(entry)) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_entry_detail(updated)) +} + +#[tauri::command] +async fn create_entry( + state: tauri::State<'_, DesktopState>, + entry: EntryDraft, +) -> Result { + let created = vault_create_entry(&state.local_vault, map_entry_draft_to_local(entry)) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_entry_detail(created)) +} + +#[tauri::command] +async fn delete_entry( + state: tauri::State<'_, DesktopState>, + entry_id: String, +) -> Result<(), String> { + vault_delete_entry(&state.local_vault, &entry_id) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(()) +} + +#[tauri::command] +async fn create_secret( + state: tauri::State<'_, DesktopState>, + entry_id: String, + secret: SecretDraft, +) -> Result { + let updated = vault_create_secret( + &state.local_vault, + &entry_id, + map_secret_draft_to_local(secret), + ) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_entry_detail(updated)) +} + +#[tauri::command] +async fn update_secret( + state: tauri::State<'_, DesktopState>, + secret: SecretUpdateDraft, +) -> Result { + let updated = vault_update_secret(&state.local_vault, map_secret_update_to_local(secret)) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_entry_detail(updated)) +} + +#[tauri::command] +async fn delete_secret( + state: tauri::State<'_, DesktopState>, + secret_id: String, +) -> Result<(), String> { + vault_delete_secret(&state.local_vault, &secret_id) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(()) +} + +#[tauri::command] +async fn reveal_secret_value( + state: tauri::State<'_, DesktopState>, + secret_id: String, +) -> Result { + let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id)?; + let value = vault_reveal_secret_value(&state.local_vault, &entry_id, &secret_name) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_secret_value(value)) +} + +#[tauri::command] +async fn secret_history( + state: tauri::State<'_, DesktopState>, + secret_id: String, +) -> Result, String> { + let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id)?; + let history = vault_secret_history(&state.local_vault, &entry_id, &secret_name) + .await + .map_err(|err| err.to_string())?; + Ok(history.into_iter().map(map_local_history_item).collect()) +} + +#[tauri::command] +async fn rollback_secret( + state: tauri::State<'_, DesktopState>, + secret_id: String, + version: Option, + history_id: Option, +) -> Result { + let _ = version; + let updated = vault_rollback_secret(&state.local_vault, &secret_id, history_id) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(map_local_entry_detail(updated)) +} + +#[tauri::command] +async fn restore_deleted_entry( + state: tauri::State<'_, DesktopState>, + entry_id: String, +) -> Result<(), String> { + vault_restore_entry(&state.local_vault, &entry_id) + .await + .map_err(|err| err.to_string())?; + sync_local_vault(&state) + .await + .map_err(|err| err.to_string())?; + Ok(()) +} + +#[tauri::command] +fn mcp_dialog_data() -> Result { + build_mcp_dialog_data().map_err(|err| err.to_string()) +} + +#[tauri::command] +fn apply_mcp_config(target: String) -> Result { + let server_config = managed_mcp_server_config(); + match target.as_str() { + "cursor" => upsert_managed_server(&CursorAdapter, "secrets", server_config), + "claude-code" => upsert_managed_server(&ClaudeCodeAdapter, "secrets", server_config), + other => Err(anyhow!("unsupported integration target: {other}")), + } + .map_err(|err| err.to_string())?; + + Ok(ApplyConfigResponse { + target, + data: build_mcp_dialog_data().map_err(|err| err.to_string())?, + }) +} + +#[tauri::command] +fn is_debug_build() -> bool { + cfg!(debug_assertions) +} + +async fn bootstrap_from_disk(state: &DesktopState) -> AnyResult { + let vault_state = vault_bootstrap(&state.local_vault).await?; + if current_device_token(state).is_err() { + return Ok(AppBootstrap { + logged_in: false, + shell: None, + vault: Some(VaultStatus { + unlocked: vault_state.unlocked, + has_master_password: vault_state.has_master_password, + }), + }); + } + + if !vault_state.unlocked { + let user = authorized_get(state, "/me") + .await? + .json::() + .await + .context("failed to decode current user")?; + return Ok(AppBootstrap { + logged_in: true, + shell: Some(ShellData { + user, + folders: Vec::new(), + entry_types: Vec::new(), + entries: Vec::new(), + selected_entry_id: None, + selected_entry: None, + }), + vault: Some(VaultStatus { + unlocked: false, + has_master_password: vault_state.has_master_password, + }), + }); + } + + let user = authorized_get(state, "/me") + .await? + .json::() + .await + .context("failed to decode current user")?; + let entries = vault_list_entries( + &state.local_vault, + &LocalEntryQuery { + folder: None, + cipher_type: None, + query: None, + deleted_only: false, + }, + ) + .await?; + let selected_entry_id = entries.first().map(|entry| entry.id.clone()); + let selected_entry = match selected_entry_id.as_ref() { + Some(id) => Some(map_local_entry_detail( + vault_entry_detail(&state.local_vault, id).await?, + )), + None => None, + }; + + Ok(AppBootstrap { + logged_in: true, + shell: Some(ShellData { + user, + folders: summarize_folders(&entries), + entry_types: summarize_entry_types(&entries), + entries: entries + .into_iter() + .map(|entry| EntryListItem { + id: entry.id, + title: entry.name, + subtitle: entry.cipher_type, + folder: entry.folder, + deleted: entry.deleted, + }) + .collect(), + selected_entry_id, + selected_entry, + }), + vault: Some(VaultStatus { + unlocked: vault_state.unlocked, + has_master_password: vault_state.has_master_password, + }), + }) +} + +async fn authorized_get(state: &DesktopState, path: &str) -> AnyResult { + authorized_get_with_query(state, path, &[]).await +} + +async fn authorized_get_with_query( + state: &DesktopState, + path: &str, + params: &[(&str, String)], +) -> AnyResult { + let token = current_device_token(state)?; + state + .client + .get(format!("{}{}", state.api_base, path)) + .query(params) + .bearer_auth(&token) + .send() + .await + .with_context(|| format!("request failed: {path}"))? + .error_for_status() + .with_context(|| format!("request returned error: {path}")) +} + +async fn sync_local_vault(state: &DesktopState) -> AnyResult<()> { + let token = current_device_token(state)?; + vault_sync_push(&state.local_vault, &state.api_base, &token).await?; + vault_sync_pull(&state.local_vault, &state.api_base, &token).await?; + Ok(()) +} + +fn map_local_entry_detail(detail: LocalEntryDetail) -> EntryDetail { + EntryDetail { + id: detail.id, + title: detail.name, + folder: detail.folder, + entry_type: detail.cipher_type, + metadata: detail + .metadata + .into_iter() + .map(|field| DetailField { + label: field.label, + value: field.value, + }) + .collect(), + secrets: detail + .secrets + .into_iter() + .map(|secret| SecretField { + id: secret.id, + name: secret.name, + secret_type: secret.secret_type, + masked_value: secret.masked_value, + version: secret.version, + }) + .collect(), + deleted: detail.deleted, + } +} + +fn map_entry_detail_to_local(detail: EntryDetail) -> LocalEntryDetail { + LocalEntryDetail { + id: detail.id, + name: detail.title, + folder: detail.folder, + cipher_type: detail.entry_type, + metadata: detail + .metadata + .into_iter() + .map(|field| LocalDetailField { + label: field.label, + value: field.value, + }) + .collect(), + secrets: detail + .secrets + .into_iter() + .map(|secret| local_vault::LocalSecretField { + id: secret.id, + name: secret.name, + secret_type: secret.secret_type, + masked_value: secret.masked_value, + version: secret.version, + }) + .collect(), + deleted: detail.deleted, + } +} + +fn map_entry_draft_to_local(entry: EntryDraft) -> LocalEntryDraft { + LocalEntryDraft { + folder: entry.folder, + name: entry.title, + cipher_type: entry.entry_type, + metadata: entry + .metadata + .into_iter() + .map(|field| LocalDetailField { + label: field.label, + value: field.value, + }) + .collect(), + secrets: entry + .secrets + .into_iter() + .map(map_secret_draft_to_local) + .collect(), + } +} + +fn map_secret_draft_to_local(secret: SecretDraft) -> LocalSecretDraft { + LocalSecretDraft { + name: secret.name, + secret_type: secret.secret_type, + value: secret.value, + } +} + +fn map_secret_update_to_local(secret: SecretUpdateDraft) -> LocalSecretUpdateDraft { + LocalSecretUpdateDraft { + id: secret.id, + name: secret.name, + secret_type: secret.secret_type, + value: secret.value, + } +} + +fn map_local_secret_value(value: LocalSecretValue) -> SecretValue { + SecretValue { + id: value.id, + entry_id: value.entry_id, + name: value.name, + secret_type: value.secret_type, + value: value.value, + version: value.version, + } +} + +fn map_local_history_item(item: LocalHistoryItem) -> SecretHistoryItem { + SecretHistoryItem { + history_id: item.history_id, + secret_id: item.secret_id, + name: item.name, + secret_type: item.secret_type, + masked_value: item.masked_value, + value: item.value, + version: item.version, + action: item.action, + created_at: item.created_at, + } +} + +fn summarize_folders(entries: &[local_vault::LocalVaultEntrySummary]) -> Vec { + let mut grouped = std::collections::BTreeMap::::new(); + for entry in entries { + *grouped.entry(entry.folder.clone()).or_default() += 1; + } + grouped + .into_iter() + .map(|(label, count)| FolderItem { + id: format!("folder-{}", label.to_lowercase()), + label, + count, + kind: "folder".to_string(), + }) + .collect() +} + +fn summarize_entry_types(entries: &[local_vault::LocalVaultEntrySummary]) -> Vec { + let mut values: Vec<_> = entries + .iter() + .map(|entry| entry.cipher_type.clone()) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn split_secret_ref_for_ui(secret_id: &str) -> Result<(String, String), String> { + secret_id + .split_once(':') + .map(|(entry_id, name)| (entry_id.to_string(), name.to_string())) + .ok_or_else(|| "invalid local secret id".to_string()) +} + +fn current_device_token(state: &DesktopState) -> AnyResult { + state + .device_token + .read() + .map_err(|_| anyhow!("failed to read device session"))? + .clone() + .context("please sign in through Secrets desktop first") +} + +fn set_device_token(state: &DesktopState, token: Option) { + if let Ok(mut guard) = state.device_token.write() { + *guard = token; + } +} + +fn legacy_token_path() -> AnyResult { + let home = std::env::var("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home) + .join(".secrets-v3") + .join("desktop") + .join("device-token")) +} + +fn clear_legacy_device_token_file() -> AnyResult<()> { + let path = legacy_token_path()?; + if path.exists() { + fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + +fn integration_status(adapter: &dyn ClientAdapter) -> IntegrationStatus { + let path = adapter.config_path(); + IntegrationStatus { + app_name: match adapter.client_name() { + "cursor" => "Cursor".to_string(), + "claude-code" => "Claude Code".to_string(), + other => other.to_string(), + }, + configured: has_managed_server(adapter, "secrets").unwrap_or(false), + config_path: path.display().to_string(), + } +} + +fn build_mcp_dialog_data() -> AnyResult { + let integrations = vec![ + integration_status(&CursorAdapter), + integration_status(&ClaudeCodeAdapter), + ]; + let mcp_json = serde_json::to_string_pretty(&serde_json::json!({ + "mcpServers": { + "secrets": managed_mcp_server_config() + } + })) + .context("failed to serialize mcp config")?; + Ok(McpDialogData { + integrations, + mcp_json, + }) +} + +fn managed_mcp_server_config() -> serde_json::Value { + let daemon_url = std::env::var("SECRETS_DAEMON_URL") + .unwrap_or_else(|_| "http://127.0.0.1:9515/mcp".to_string()); + serde_json::json!({ + "url": daemon_url + }) +} + +fn apply_mcp_config_to_all() -> AnyResult<()> { + let server_config = managed_mcp_server_config(); + upsert_managed_server(&CursorAdapter, "secrets", server_config.clone())?; + upsert_managed_server(&ClaudeCodeAdapter, "secrets", server_config)?; + Ok(()) +} + +fn load_google_desktop_client() -> AnyResult { + let configured = std::env::var("GOOGLE_OAUTH_CLIENT_FILE") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join("client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json") + }); + let raw = fs::read_to_string(&configured) + .with_context(|| format!("failed to read {}", configured.display()))?; + let parsed: GoogleDesktopClientFile = + serde_json::from_str(&raw).context("failed to parse google desktop client file")?; + Ok(parsed.installed) +} + +async fn complete_google_desktop_login( + state: &DesktopState, + google_client: &GoogleDesktopClient, +) -> AnyResult { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("failed to bind local oauth callback listener")?; + let port = listener + .local_addr() + .context("failed to read callback listener address")? + .port(); + let redirect_uri = format!("http://localhost:{port}/oauth/callback"); + let verifier = new_device_fingerprint(); + let challenge = pkce_challenge(&verifier); + + let mut auth_url = Url::parse(&google_client.auth_uri).context("invalid google auth uri")?; + auth_url + .query_pairs_mut() + .append_pair("client_id", &google_client.client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", "openid email profile") + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("access_type", "offline") + .append_pair("prompt", "consent"); + + open_system_browser(auth_url.as_str())?; + let auth_code = wait_for_google_callback(listener).await?; + + let mut form = vec![ + ("client_id", google_client.client_id.clone()), + ("code", auth_code), + ("code_verifier", verifier), + ("grant_type", "authorization_code".to_string()), + ("redirect_uri", redirect_uri), + ]; + if let Some(secret) = google_client.client_secret.clone() { + form.push(("client_secret", secret)); + } + + let google_token = state + .client + .post(&google_client.token_uri) + .form(&form) + .send() + .await + .context("failed to exchange google auth code")? + .error_for_status() + .context("google token exchange failed")? + .json::() + .await + .context("failed to decode google token response")?; + + let response = state + .client + .post(format!("{}/auth/google/desktop-login", state.api_base)) + .json(&GoogleDesktopLoginRequest { + access_token: google_token.access_token, + device_name: current_device_name(), + platform: std::env::consts::OS.to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + device_fingerprint: load_or_create_device_fingerprint()?, + }) + .send() + .await + .context("failed to call google desktop login API")?; + + response + .error_for_status() + .context("google desktop login failed")? + .json::() + .await + .context("failed to decode desktop login response") +} + +async fn wait_for_google_callback(listener: TcpListener) -> AnyResult { + let (mut socket, _) = listener + .accept() + .await + .context("failed to accept oauth callback connection")?; + let mut buffer = [0_u8; 4096]; + let read = socket + .read(&mut buffer) + .await + .context("failed to read oauth callback request")?; + let request = String::from_utf8_lossy(&buffer[..read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .context("invalid oauth callback request line")?; + let callback_url = Url::parse(&format!("http://localhost{path}")) + .context("failed to parse oauth callback url")?; + let code = callback_url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, value)| value.into_owned()) + .context("oauth callback missing code")?; + + let body = "

登录成功,可以返回 Secrets。

"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .context("failed to respond to oauth callback")?; + Ok(code) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn open_system_browser(url: &str) -> AnyResult<()> { + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(url) + .spawn() + .context("failed to launch browser with open")?; + return Ok(()); + } + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .context("failed to launch browser with start")?; + return Ok(()); + } + #[cfg(target_os = "linux")] + { + Command::new("xdg-open") + .arg(url) + .spawn() + .context("failed to launch browser with xdg-open")?; + return Ok(()); + } + #[allow(unreachable_code)] + Err(anyhow!("unsupported platform for browser launch")) +} + +fn device_fingerprint_path() -> AnyResult { + let home = std::env::var("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home) + .join(".secrets-v3") + .join("desktop") + .join("device-fingerprint")) +} + +fn load_or_create_device_fingerprint() -> AnyResult { + let path = device_fingerprint_path()?; + if path.exists() { + return fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display())) + .map(|value| value.trim().to_string()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let fingerprint = new_device_fingerprint(); + fs::write(&path, &fingerprint) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(fingerprint) +} + +fn current_device_name() -> String { + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| format!("Secrets Desktop ({})", std::env::consts::OS)) +} + +fn main() { + let api_base = + std::env::var("SECRETS_API_BASE").unwrap_or_else(|_| "http://127.0.0.1:9415".to_string()); + let session_bind = std::env::var("SECRETS_DESKTOP_SESSION_BIND") + .unwrap_or_else(|_| "127.0.0.1:9520".to_string()); + let local_vault = + tauri::async_runtime::block_on(open_or_create_local_vault()).expect("open local vault"); + tauri::Builder::default() + .manage(DesktopState { + api_base, + session_bind, + client: Client::new(), + device_token: Arc::new(RwLock::new(None)), + local_vault, + }) + .setup(|app| { + let state = app.state::().inner().clone(); + let _ = clear_legacy_device_token_file(); + tauri::async_runtime::spawn(async move { + if let Err(err) = start_desktop_session_server(state).await { + eprintln!("desktop session relay failed: {err}"); + } + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + app_bootstrap, + continue_demo_login, + logout, + vault_status, + setup_master_password, + unlock_vault, + lock_vault, + is_debug_build, + device_list, + create_entry, + entry_detail, + list_entries, + update_entry_detail, + delete_entry, + create_secret, + update_secret, + delete_secret, + reveal_secret_value, + secret_history, + rollback_secret, + restore_deleted_entry, + mcp_dialog_data, + apply_mcp_config + ]) + .run(tauri::generate_context!()) + .expect("failed to run tauri desktop app"); +} diff --git a/apps/desktop/src-tauri/src/session_api.rs b/apps/desktop/src-tauri/src/session_api.rs new file mode 100644 index 0000000..3ff453e --- /dev/null +++ b/apps/desktop/src-tauri/src/session_api.rs @@ -0,0 +1,356 @@ +use anyhow::{Context, Result as AnyResult}; +use axum::{ + Router, + body::{Body, to_bytes}, + extract::{Request, State as AxumState}, + http::{StatusCode as AxumStatusCode, header}, + response::Response, + routing::{any, get, post}, +}; +use url::Url; + +use crate::local_vault::{ + LocalEntryQuery, bootstrap as vault_bootstrap, create_entry as vault_create_entry, + create_secret as vault_create_secret, delete_entry as vault_delete_entry, + delete_secret as vault_delete_secret, entry_detail as vault_entry_detail, + list_entries as vault_list_entries, restore_entry as vault_restore_entry, + reveal_secret_value as vault_reveal_secret_value, rollback_secret as vault_rollback_secret, + secret_history as vault_secret_history, update_entry as vault_update_entry, + update_secret as vault_update_secret, +}; +use crate::{ + DesktopState, EntryDetail, EntryDraft, EntryListItem, EntryListQuery, SecretDraft, + SecretUpdateDraft, current_device_token, map_entry_detail_to_local, map_entry_draft_to_local, + map_local_entry_detail, map_local_history_item, map_local_secret_value, + map_secret_draft_to_local, map_secret_update_to_local, split_secret_ref_for_ui, + sync_local_vault, +}; + +pub async fn desktop_session_health( + AxumState(state): AxumState, +) -> Result<&'static str, AxumStatusCode> { + current_device_token(&state) + .map(|_| "ok") + .map_err(|_| AxumStatusCode::UNAUTHORIZED) +} + +pub async fn desktop_session_api( + AxumState(state): AxumState, + request: Request, +) -> Response { + let (parts, body) = request.into_parts(); + let path_and_query = parts + .uri + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); + + let body_bytes = match to_bytes(body, 1024 * 1024).await { + Ok(bytes) => bytes, + Err(_) => { + return Response::builder() + .status(AxumStatusCode::BAD_REQUEST) + .body(Body::from("failed to read relay request body")) + .expect("build relay bad request"); + } + }; + + handle_local_session_request(&state, parts.method.as_str(), path_and_query, &body_bytes) + .await + .unwrap_or_else(|| { + Response::builder() + .status(AxumStatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .body(Body::from( + r#"{"error":"desktop local vault route not found"}"#, + )) + .expect("build local session not found response") + }) +} + +async fn handle_local_session_request( + state: &DesktopState, + method: &str, + path_and_query: &str, + body_bytes: &[u8], +) -> Option { + let path = path_and_query.split('?').next().unwrap_or(path_and_query); + let make_json = |status: AxumStatusCode, value: serde_json::Value| { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .body(Body::from(value.to_string())) + .expect("build local session response") + }; + + match (method, path) { + ("GET", "/vault/status") => { + let status = vault_bootstrap(&state.local_vault).await.ok()?; + Some(make_json( + AxumStatusCode::OK, + serde_json::json!({ + "unlocked": status.unlocked, + "has_master_password": status.has_master_password + }), + )) + } + ("GET", "/vault/entries") => { + let url = format!("http://localhost{path_and_query}"); + let parsed = Url::parse(&url).ok()?; + let mut query = EntryListQuery { + folder: None, + entry_type: None, + query: None, + deleted_only: false, + }; + for (key, value) in parsed.query_pairs() { + match key.as_ref() { + "folder" => query.folder = Some(value.into_owned()), + "entry_type" => query.entry_type = Some(value.into_owned()), + "query" => query.query = Some(value.into_owned()), + "deleted_only" => query.deleted_only = value == "true", + _ => {} + } + } + let entries = vault_list_entries( + &state.local_vault, + &LocalEntryQuery { + folder: query.folder, + cipher_type: query.entry_type, + query: query.query, + deleted_only: query.deleted_only, + }, + ) + .await + .ok()?; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value( + entries + .into_iter() + .map(|entry| EntryListItem { + id: entry.id, + title: entry.name, + subtitle: entry.cipher_type, + folder: entry.folder, + deleted: entry.deleted, + }) + .collect::>(), + ) + .ok()?, + )) + } + _ if method == "GET" && path.starts_with("/vault/entries/") => { + let entry_id = path.trim_start_matches("/vault/entries/"); + let detail = vault_entry_detail(&state.local_vault, entry_id) + .await + .ok()?; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_entry_detail(detail)).ok()?, + )) + } + ("POST", "/vault/entries") => { + let draft: EntryDraft = serde_json::from_slice(body_bytes).ok()?; + let created = vault_create_entry(&state.local_vault, map_entry_draft_to_local(draft)) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_entry_detail(created)).ok()?, + )) + } + _ if method == "PATCH" && path.starts_with("/vault/entries/") => { + let entry_id = path.trim_start_matches("/vault/entries/").to_string(); + let mut detail: EntryDetail = serde_json::from_slice(body_bytes).ok()?; + detail.id = entry_id; + let updated = vault_update_entry(&state.local_vault, map_entry_detail_to_local(detail)) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_entry_detail(updated)).ok()?, + )) + } + _ if method == "POST" + && path.starts_with("/vault/entries/") + && path.ends_with("/delete") => + { + let entry_id = path + .trim_start_matches("/vault/entries/") + .trim_end_matches("/delete") + .trim_end_matches('/'); + vault_delete_entry(&state.local_vault, entry_id) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::json!({ "ok": true }), + )) + } + _ if method == "POST" + && path.starts_with("/vault/entries/") + && path.ends_with("/restore") => + { + let entry_id = path + .trim_start_matches("/vault/entries/") + .trim_end_matches("/restore") + .trim_end_matches('/'); + vault_restore_entry(&state.local_vault, entry_id) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::json!({ "ok": true }), + )) + } + _ if method == "POST" + && path.starts_with("/vault/entries/") + && path.ends_with("/secrets") => + { + let entry_id = path + .trim_start_matches("/vault/entries/") + .trim_end_matches("/secrets") + .trim_end_matches('/'); + let secret: SecretDraft = serde_json::from_slice(body_bytes).ok()?; + let updated = vault_create_secret( + &state.local_vault, + entry_id, + map_secret_draft_to_local(secret), + ) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_entry_detail(updated)).ok()?, + )) + } + _ if method == "GET" && path.starts_with("/vault/secrets/") && path.ends_with("/value") => { + let secret_id = path + .trim_start_matches("/vault/secrets/") + .trim_end_matches("/value") + .trim_end_matches('/') + .to_string(); + let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?; + let value = vault_reveal_secret_value(&state.local_vault, &entry_id, &secret_name) + .await + .ok()?; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_secret_value(value)).ok()?, + )) + } + _ if method == "GET" + && path.starts_with("/vault/secrets/") + && path.ends_with("/history") => + { + let secret_id = path + .trim_start_matches("/vault/secrets/") + .trim_end_matches("/history") + .trim_end_matches('/') + .to_string(); + let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?; + let history = vault_secret_history(&state.local_vault, &entry_id, &secret_name) + .await + .ok()?; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value( + history + .into_iter() + .map(map_local_history_item) + .collect::>(), + ) + .ok()?, + )) + } + _ if method == "PATCH" && path.starts_with("/vault/secrets/") => { + let secret_id = path.trim_start_matches("/vault/secrets/").to_string(); + let mut update: SecretUpdateDraft = serde_json::from_slice(body_bytes).ok()?; + update.id = secret_id; + let updated = + vault_update_secret(&state.local_vault, map_secret_update_to_local(update)) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_entry_detail(updated)).ok()?, + )) + } + _ if method == "POST" + && path.starts_with("/vault/secrets/") + && path.ends_with("/delete") => + { + let secret_id = path + .trim_start_matches("/vault/secrets/") + .trim_end_matches("/delete") + .trim_end_matches('/'); + vault_delete_secret(&state.local_vault, secret_id) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::json!({ "ok": true }), + )) + } + _ if method == "POST" + && path.starts_with("/vault/secrets/") + && path.ends_with("/rollback") => + { + let secret_id = path + .trim_start_matches("/vault/secrets/") + .trim_end_matches("/rollback") + .trim_end_matches('/') + .to_string(); + let payload: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; + let updated = vault_rollback_secret( + &state.local_vault, + &secret_id, + payload.get("history_id").and_then(|value| value.as_i64()), + ) + .await + .ok()?; + let _ = sync_local_vault(state).await; + Some(make_json( + AxumStatusCode::OK, + serde_json::to_value(map_local_entry_detail(updated)).ok()?, + )) + } + _ => None, + } +} + +pub async fn start_desktop_session_server(state: DesktopState) -> AnyResult<()> { + let app = Router::new() + .route("/healthz", get(desktop_session_health)) + .route("/vault/status", get(desktop_session_api)) + .route("/vault/entries", any(desktop_session_api)) + .route("/vault/entries/{id}", any(desktop_session_api)) + .route("/vault/entries/{id}/delete", post(desktop_session_api)) + .route("/vault/entries/{id}/restore", post(desktop_session_api)) + .route("/vault/entries/{id}/secrets", post(desktop_session_api)) + .route("/vault/secrets/{id}", any(desktop_session_api)) + .route("/vault/secrets/{id}/value", get(desktop_session_api)) + .route("/vault/secrets/{id}/history", get(desktop_session_api)) + .route("/vault/secrets/{id}/delete", post(desktop_session_api)) + .route("/vault/secrets/{id}/rollback", post(desktop_session_api)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind(&state.session_bind) + .await + .with_context(|| { + format!( + "failed to bind desktop session relay {}", + state.session_bind + ) + })?; + axum::serve(listener, app) + .await + .context("desktop session relay server error") +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..24c3869 --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Secrets", + "version": "3.0.0", + "identifier": "dev.refining.secrets", + "build": { + "beforeDevCommand": "", + "beforeBuildCommand": "", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Secrets", + "width": 420, + "height": 400, + "minWidth": 420, + "minHeight": 400, + "resizable": true, + "titleBarStyle": "overlay", + "hiddenTitle": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false + } +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..00ddf75 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "secrets-application" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_application" +path = "src/lib.rs" + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +uuid.workspace = true + +secrets-domain = { path = "../domain" } diff --git a/crates/application/src/conflict.rs b/crates/application/src/conflict.rs new file mode 100644 index 0000000..388ee2d --- /dev/null +++ b/crates/application/src/conflict.rs @@ -0,0 +1,9 @@ +use secrets_domain::VaultObjectEnvelope; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct RevisionConflict { + pub change_id: Uuid, + pub object_id: Uuid, + pub server_object: Option, +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..88ba1b5 --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,3 @@ +pub mod conflict; +pub mod sync; +pub mod vault_store; diff --git a/crates/application/src/sync.rs b/crates/application/src/sync.rs new file mode 100644 index 0000000..15b8d18 --- /dev/null +++ b/crates/application/src/sync.rs @@ -0,0 +1,252 @@ +use anyhow::Result; +use sqlx::PgPool; +use uuid::Uuid; + +use secrets_domain::{ + SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest, + SyncPushResponse, VaultObjectChange, VaultObjectEnvelope, +}; + +use crate::vault_store::{ + get_object, list_objects_since, list_tombstones_since, max_server_revision, +}; + +fn detect_conflict( + change: &VaultObjectChange, + existing: Option<&VaultObjectEnvelope>, +) -> Option { + match (change.base_revision, existing) { + (Some(base_revision), Some(server_object)) if server_object.revision != base_revision => { + Some(SyncConflict { + change_id: change.change_id, + object_id: change.object_id, + reason: "revision_conflict".to_string(), + server_object: Some(server_object.clone()), + }) + } + _ if !matches!(change.operation.as_str(), "upsert" | "delete") => Some(SyncConflict { + change_id: change.change_id, + object_id: change.object_id, + reason: "unsupported_operation".to_string(), + server_object: existing.cloned(), + }), + _ => None, + } +} + +pub async fn sync_pull( + pool: &PgPool, + user_id: Uuid, + request: SyncPullRequest, +) -> Result { + let cursor = request.cursor.unwrap_or(0).max(0); + let limit = request.limit.unwrap_or(200).clamp(1, 500); + let objects = list_objects_since(pool, user_id, cursor, limit).await?; + let tombstones = if request.include_deleted { + list_tombstones_since(pool, user_id, cursor, limit).await? + } else { + Vec::new() + }; + let server_revision = max_server_revision(pool, user_id).await?; + let next_cursor = objects + .last() + .map(|object| object.revision) + .unwrap_or(cursor); + + Ok(SyncPullResponse { + server_revision, + next_cursor, + has_more: (objects.len() as i64) >= limit, + objects, + tombstones, + }) +} + +pub async fn sync_push( + pool: &PgPool, + user_id: Uuid, + request: SyncPushRequest, +) -> Result { + let mut accepted = Vec::new(); + let mut conflicts = Vec::new(); + + for change in request.changes { + let existing = get_object(pool, user_id, change.object_id).await?; + if let Some(conflict) = detect_conflict(&change, existing.as_ref()) { + conflicts.push(conflict); + continue; + } + + let next_revision = existing + .as_ref() + .map(|object| object.revision + 1) + .unwrap_or(1); + let next_cipher_version = change.cipher_version.unwrap_or(1); + let next_ciphertext = change.ciphertext.clone().unwrap_or_default(); + let next_content_hash = change.content_hash.clone().unwrap_or_default(); + let next_deleted_at = if change.operation == "delete" { + Some(chrono::Utc::now()) + } else { + None + }; + + match change.operation.as_str() { + "upsert" => { + sqlx::query( + r#" + INSERT INTO vault_objects ( + object_id, user_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, created_by_device + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NOW(), NULL) + ON CONFLICT (object_id) + DO UPDATE SET + revision = EXCLUDED.revision, + cipher_version = EXCLUDED.cipher_version, + ciphertext = EXCLUDED.ciphertext, + content_hash = EXCLUDED.content_hash, + deleted_at = NULL, + updated_at = NOW() + "#, + ) + .bind(change.object_id) + .bind(user_id) + .bind(change.object_kind.as_str()) + .bind(next_revision) + .bind(next_cipher_version) + .bind(next_ciphertext.clone()) + .bind(next_content_hash.clone()) + .execute(pool) + .await?; + } + "delete" => { + sqlx::query( + r#" + UPDATE vault_objects + SET revision = $1, deleted_at = NOW(), updated_at = NOW() + WHERE object_id = $2 + AND user_id = $3 + "#, + ) + .bind(next_revision) + .bind(change.object_id) + .bind(user_id) + .execute(pool) + .await?; + } + _ => unreachable!("unsupported operations are filtered by detect_conflict"), + } + + sqlx::query( + r#" + INSERT INTO vault_object_revisions ( + object_id, user_id, revision, cipher_version, ciphertext, content_hash, deleted_at, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + "#, + ) + .bind(change.object_id) + .bind(user_id) + .bind(next_revision) + .bind(next_cipher_version) + .bind(next_ciphertext) + .bind(next_content_hash) + .bind(next_deleted_at) + .execute(pool) + .await?; + + accepted.push(SyncAcceptedChange { + change_id: change.change_id, + object_id: change.object_id, + revision: next_revision, + }); + } + + let server_revision = max_server_revision(pool, user_id).await?; + Ok(SyncPushResponse { + server_revision, + accepted, + conflicts, + }) +} + +pub async fn fetch_object( + pool: &PgPool, + user_id: Uuid, + object_id: Uuid, +) -> Result> { + get_object(pool, user_id, object_id).await +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use secrets_domain::{VaultObjectChange, VaultObjectKind}; + use uuid::Uuid; + + fn sample_change(operation: &str, base_revision: Option) -> VaultObjectChange { + VaultObjectChange { + change_id: Uuid::nil(), + object_id: Uuid::max(), + object_kind: VaultObjectKind::Cipher, + operation: operation.to_string(), + base_revision, + cipher_version: Some(1), + ciphertext: Some(vec![1, 2, 3]), + content_hash: Some("sha256:test".to_string()), + } + } + + fn sample_object(revision: i64) -> VaultObjectEnvelope { + VaultObjectEnvelope { + object_id: Uuid::max(), + object_kind: VaultObjectKind::Cipher, + revision, + cipher_version: 1, + ciphertext: vec![9, 9, 9], + content_hash: "sha256:server".to_string(), + deleted_at: None, + updated_at: Utc::now(), + } + } + + #[test] + fn conflict_when_base_revision_is_stale() { + let mut change = sample_change("upsert", Some(3)); + let server = sample_object(5); + change.object_id = server.object_id; + + let conflict = detect_conflict(&change, Some(&server)).expect("expected conflict"); + + assert_eq!(conflict.reason, "revision_conflict"); + assert_eq!(conflict.object_id, server.object_id); + assert_eq!( + conflict + .server_object + .as_ref() + .map(|object| object.revision), + Some(5) + ); + } + + #[test] + fn no_conflict_when_revision_matches() { + let mut change = sample_change("upsert", Some(5)); + let server = sample_object(5); + change.object_id = server.object_id; + + let conflict = detect_conflict(&change, Some(&server)); + + assert!(conflict.is_none()); + } + + #[test] + fn unsupported_operation_is_conflict() { + let change = sample_change("merge", None); + + let conflict = detect_conflict(&change, None).expect("expected unsupported operation"); + + assert_eq!(conflict.reason, "unsupported_operation"); + assert!(conflict.server_object.is_none()); + } +} diff --git a/crates/application/src/vault_store.rs b/crates/application/src/vault_store.rs new file mode 100644 index 0000000..1a45d72 --- /dev/null +++ b/crates/application/src/vault_store.rs @@ -0,0 +1,147 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use secrets_domain::{VaultObjectEnvelope, VaultObjectKind, VaultTombstone}; + +#[derive(Debug, sqlx::FromRow)] +struct VaultObjectRow { + object_id: Uuid, + _object_kind: String, + revision: i64, + cipher_version: i32, + ciphertext: Vec, + content_hash: String, + deleted_at: Option>, + updated_at: DateTime, +} + +impl From for VaultObjectEnvelope { + fn from(row: VaultObjectRow) -> Self { + Self { + object_id: row.object_id, + object_kind: VaultObjectKind::Cipher, + revision: row.revision, + cipher_version: row.cipher_version, + ciphertext: row.ciphertext, + content_hash: row.content_hash, + deleted_at: row.deleted_at, + updated_at: row.updated_at, + } + } +} + +pub async fn list_objects_since( + pool: &PgPool, + user_id: Uuid, + cursor: i64, + limit: i64, +) -> Result> { + let rows = sqlx::query_as::<_, VaultObjectRow>( + r#" + SELECT + object_id, + object_kind AS _object_kind, + revision, + cipher_version, + ciphertext, + content_hash, + deleted_at, + updated_at + FROM vault_objects + WHERE user_id = $1 + AND revision > $2 + ORDER BY revision ASC + LIMIT $3 + "#, + ) + .bind(user_id) + .bind(cursor) + .bind(limit.max(1)) + .fetch_all(pool) + .await + .context("failed to list vault objects")?; + + Ok(rows.into_iter().map(Into::into).collect()) +} + +pub async fn get_object( + pool: &PgPool, + user_id: Uuid, + object_id: Uuid, +) -> Result> { + let row = sqlx::query_as::<_, VaultObjectRow>( + r#" + SELECT + object_id, + object_kind AS _object_kind, + revision, + cipher_version, + ciphertext, + content_hash, + deleted_at, + updated_at + FROM vault_objects + WHERE user_id = $1 + AND object_id = $2 + "#, + ) + .bind(user_id) + .bind(object_id) + .fetch_optional(pool) + .await + .context("failed to load vault object")?; + + Ok(row.map(Into::into)) +} + +pub async fn list_tombstones_since( + pool: &PgPool, + user_id: Uuid, + cursor: i64, + limit: i64, +) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, i64, DateTime)>( + r#" + SELECT object_id, revision, deleted_at + FROM vault_objects + WHERE user_id = $1 + AND revision > $2 + AND deleted_at IS NOT NULL + ORDER BY revision ASC + LIMIT $3 + "#, + ) + .bind(user_id) + .bind(cursor) + .bind(limit.max(1)) + .fetch_all(pool) + .await + .context("failed to list tombstones")?; + + Ok(rows + .into_iter() + .map(|(object_id, revision, deleted_at)| VaultTombstone { + object_id, + revision, + deleted_at, + }) + .collect()) +} + +pub async fn max_server_revision(pool: &PgPool, user_id: Uuid) -> Result { + let revision = sqlx::query_scalar::<_, Option>( + r#" + SELECT MAX(revision) + FROM vault_objects + WHERE user_id = $1 + "#, + ) + .bind(user_id) + .fetch_one(pool) + .await + .context("failed to load max server revision")?; + + Ok(revision.unwrap_or(0)) +} diff --git a/crates/client-integrations/Cargo.toml b/crates/client-integrations/Cargo.toml new file mode 100644 index 0000000..7f4069c --- /dev/null +++ b/crates/client-integrations/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "secrets-client-integrations" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_client_integrations" +path = "src/lib.rs" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/client-integrations/src/lib.rs b/crates/client-integrations/src/lib.rs new file mode 100644 index 0000000..9f10774 --- /dev/null +++ b/crates/client-integrations/src/lib.rs @@ -0,0 +1,162 @@ +use anyhow::{Context, Result}; +use serde_json::{Map, Value}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub trait ClientAdapter { + fn client_name(&self) -> &'static str; + fn config_path(&self) -> PathBuf; +} + +pub struct CursorAdapter; + +impl ClientAdapter for CursorAdapter { + fn client_name(&self) -> &'static str { + "cursor" + } + + fn config_path(&self) -> PathBuf { + default_home().join(".cursor").join("mcp.json") + } +} + +pub struct ClaudeCodeAdapter; + +impl ClientAdapter for ClaudeCodeAdapter { + fn client_name(&self) -> &'static str { + "claude-code" + } + + fn config_path(&self) -> PathBuf { + default_home().join(".claude").join("mcp.json") + } +} + +fn default_home() -> PathBuf { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +pub fn has_managed_server(adapter: &dyn ClientAdapter, server_name: &str) -> Result { + let path = adapter.config_path(); + let root = read_config_or_default(&path)?; + Ok(root + .get("mcpServers") + .and_then(Value::as_object) + .is_some_and(|servers| servers.contains_key(server_name))) +} + +pub fn upsert_managed_server( + adapter: &dyn ClientAdapter, + server_name: &str, + server_config: Value, +) -> Result<()> { + let path = adapter.config_path(); + let mut root = read_config_or_default(&path)?; + let root_object = ensure_object(&mut root); + let mcp_servers = root_object + .entry("mcpServers".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let servers_object = ensure_object(mcp_servers); + servers_object.insert(server_name.to_string(), server_config); + write_config_atomically(&path, &root) +} + +fn read_config_or_default(path: &Path) -> Result { + if !path.exists() { + return Ok(Value::Object(Map::new())); + } + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display())) +} + +fn write_config_atomically(path: &Path, value: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let tmp_path = path.with_extension("json.tmp"); + let body = serde_json::to_string_pretty(value).context("failed to serialize mcp config")?; + fs::write(&tmp_path, body) + .with_context(|| format!("failed to write {}", tmp_path.display()))?; + fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?; + Ok(()) +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value.as_object_mut().expect("object just ensured") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TestAdapter { + path: PathBuf, + } + + impl ClientAdapter for TestAdapter { + fn client_name(&self) -> &'static str { + "test" + } + + fn config_path(&self) -> PathBuf { + self.path.clone() + } + } + + #[test] + fn upsert_preserves_other_servers() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let base = std::env::temp_dir().join(format!("secrets-client-integrations-{unique}")); + let adapter = TestAdapter { + path: base.join("mcp.json"), + }; + fs::create_dir_all(adapter.path.parent().expect("parent")).expect("mkdir"); + fs::write( + &adapter.path, + r#"{"mcpServers":{"postgres":{"command":"npx"},"secrets":{"url":"http://old"}}}"#, + ) + .expect("seed config"); + + upsert_managed_server( + &adapter, + "secrets", + serde_json::json!({ + "url": "http://127.0.0.1:9515/mcp" + }), + ) + .expect("upsert config"); + + let root: Value = + serde_json::from_str(&fs::read_to_string(&adapter.path).expect("read back")) + .expect("parse back"); + let servers = root + .get("mcpServers") + .and_then(Value::as_object) + .expect("mcpServers object"); + assert!(servers.contains_key("postgres")); + assert_eq!( + servers + .get("secrets") + .and_then(Value::as_object) + .and_then(|value| value.get("url")) + .and_then(Value::as_str), + Some("http://127.0.0.1:9515/mcp") + ); + + let _ = fs::remove_dir_all(base); + } +} diff --git a/crates/crypto/Cargo.toml b/crates/crypto/Cargo.toml new file mode 100644 index 0000000..d5b54ed --- /dev/null +++ b/crates/crypto/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "secrets-crypto" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_crypto" +path = "src/lib.rs" + +[dependencies] +aes-gcm.workspace = true +anyhow.workspace = true +hex.workspace = true +rand.workspace = true diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs new file mode 100644 index 0000000..790d978 --- /dev/null +++ b/crates/crypto/src/lib.rs @@ -0,0 +1,47 @@ +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{Context, Result}; +use rand::Rng; + +pub const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-v3-key-check"; + +pub fn decode_hex(input: &str) -> Result> { + hex::decode(input.trim()).context("invalid hex") +} + +pub fn encode_hex(input: &[u8]) -> String { + hex::encode(input) +} + +pub fn extract_key_32(input: &str) -> Result<[u8; 32]> { + let bytes = decode_hex(input)?; + let key: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("expected 32-byte key"))?; + Ok(key) +} + +pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { + let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?; + let mut nonce_bytes = [0_u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let mut out = nonce_bytes.to_vec(); + out.extend( + cipher + .encrypt(nonce, plaintext) + .map_err(|_| anyhow::anyhow!("encryption failed"))?, + ); + Ok(out) +} + +pub fn decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result> { + if ciphertext.len() < 12 { + anyhow::bail!("ciphertext too short"); + } + let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?; + let (nonce, body) = ciphertext.split_at(12); + cipher + .decrypt(Nonce::from_slice(nonce), body) + .map_err(|_| anyhow::anyhow!("decryption failed")) +} diff --git a/crates/desktop-daemon/Cargo.toml b/crates/desktop-daemon/Cargo.toml new file mode 100644 index 0000000..0a54459 --- /dev/null +++ b/crates/desktop-daemon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "secrets-desktop-daemon" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_desktop_daemon" +path = "src/lib.rs" + +[[bin]] +name = "secrets-desktop-daemon" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +axum.workspace = true +dotenvy.workspace = true +reqwest = { workspace = true, features = ["stream"] } +rmcp.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +secrets-device-auth = { path = "../device-auth" } diff --git a/crates/desktop-daemon/src/config.rs b/crates/desktop-daemon/src/config.rs new file mode 100644 index 0000000..784216d --- /dev/null +++ b/crates/desktop-daemon/src/config.rs @@ -0,0 +1,23 @@ +use anyhow::Result; + +#[derive(Debug, Clone)] +pub struct DaemonConfig { + pub bind: String, +} + +pub fn load_config() -> Result { + let bind = + std::env::var("SECRETS_DAEMON_BIND").unwrap_or_else(|_| "127.0.0.1:9515".to_string()); + if bind.trim().is_empty() { + anyhow::bail!("SECRETS_DAEMON_BIND must not be empty"); + } + Ok(DaemonConfig { bind }) +} + +pub fn load_persisted_device_token() -> Result> { + let token = std::env::var("SECRETS_DEVICE_LOGIN_TOKEN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + Ok(token) +} diff --git a/crates/secrets-mcp-local/src/exec.rs b/crates/desktop-daemon/src/exec.rs similarity index 64% rename from crates/secrets-mcp-local/src/exec.rs rename to crates/desktop-daemon/src/exec.rs index f965188..8d30ebf 100644 --- a/crates/secrets-mcp-local/src/exec.rs +++ b/crates/desktop-daemon/src/exec.rs @@ -13,7 +13,6 @@ const MAX_OUTPUT_CHARS: usize = 64 * 1024; #[derive(Clone, Debug, Deserialize)] pub struct TargetExecInput { pub target_ref: Option, - pub target: Option, pub command: String, pub timeout_secs: Option, pub working_dir: Option, @@ -138,63 +137,3 @@ pub async fn execute_command( stderr_truncated, }) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::target::ExecutionTarget; - use serde_json::json; - - #[tokio::test] - async fn execute_command_injects_target_env() { - let target = ExecutionTarget { - resolved: ResolvedTarget { - id: "entry-1".to_string(), - folder: "refining".to_string(), - name: "api".to_string(), - entry_type: Some("service".to_string()), - }, - env: BTreeMap::from([ - ("TARGET_HOST".to_string(), "47.238.146.244".to_string()), - ("TARGET_API_KEY".to_string(), "sk_test_123".to_string()), - ]), - }; - let input = TargetExecInput { - target_ref: Some("entry-1".to_string()), - target: None, - command: "printf '%s|%s' \"$TARGET_HOST\" \"$TARGET_API_KEY\"".to_string(), - timeout_secs: Some(5), - working_dir: None, - env_overrides: None, - }; - let result = execute_command(&input, &target, 5).await.unwrap(); - assert_eq!(result.exit_code, Some(0)); - assert_eq!(result.stdout, "47.238.146.244|sk_test_123"); - } - - #[tokio::test] - async fn execute_command_rejects_reserved_target_override() { - let target = ExecutionTarget { - resolved: ResolvedTarget { - id: "entry-1".to_string(), - folder: "refining".to_string(), - name: "api".to_string(), - entry_type: Some("service".to_string()), - }, - env: BTreeMap::from([("TARGET_HOST".to_string(), "47.238.146.244".to_string())]), - }; - let input = TargetExecInput { - target_ref: Some("entry-1".to_string()), - target: None, - command: "echo test".to_string(), - timeout_secs: Some(5), - working_dir: None, - env_overrides: Some(serde_json::from_value(json!({"TARGET_HOST":"override"})).unwrap()), - }; - let err = execute_command(&input, &target, 5).await.unwrap_err(); - assert!( - err.to_string() - .contains("cannot override reserved TARGET_* variables") - ); - } -} diff --git a/crates/desktop-daemon/src/lib.rs b/crates/desktop-daemon/src/lib.rs new file mode 100644 index 0000000..292b566 --- /dev/null +++ b/crates/desktop-daemon/src/lib.rs @@ -0,0 +1,684 @@ +pub mod config; +pub mod exec; +pub mod target; +pub mod vault_client; + +use std::collections::HashMap; + +use anyhow::{Context, Result, anyhow}; +use axum::{ + Router, + body::Body, + extract::State, + http::{StatusCode, header}, + response::Response, + routing::{any, get}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + exec::{TargetExecInput, execute_command}, + target::{TargetSnapshot, build_execution_target}, + vault_client::{ + EntryDetail, EntrySummary, SecretHistoryItem, SecretValueField, authorized_get, + authorized_patch, authorized_post, entry_detail_payload, fetch_entry_detail, + fetch_revealed_entry_secrets, + }, +}; + +#[derive(Clone)] +pub struct AppState { + session_base: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct JsonRpcRequest { + #[serde(default)] + id: Value, + method: String, + #[serde(default)] + params: Value, +} + +fn json_response(status: StatusCode, value: Value) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .body(Body::from(value.to_string())) + .expect("build response") +} + +fn jsonrpc_result_response(id: Value, result: Value) -> Response { + json_response( + StatusCode::OK, + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }), + ) +} + +fn tool_success_response(id: Value, value: Value) -> Response { + let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); + jsonrpc_result_response( + id, + json!({ + "content": [ + { + "type": "text", + "text": pretty + } + ], + "isError": false + }), + ) +} + +fn tool_error_response(id: Value, message: impl Into) -> Response { + jsonrpc_result_response( + id, + json!({ + "content": [ + { + "type": "text", + "text": message.into() + } + ], + "isError": true + }), + ) +} + +fn initialize_response(id: Value) -> Response { + let session_id = format!( + "desktop-daemon-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + ); + let payload = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "secrets-desktop-daemon", + "version": env!("CARGO_PKG_VERSION"), + "title": "Secrets Desktop Daemon" + }, + "instructions": "Preferred tools: secrets_entry_find, secrets_entry_get, secrets_entry_add, secrets_entry_update, secrets_entry_delete, secrets_entry_restore, secrets_secret_add, secrets_secret_update, secrets_secret_delete, secrets_secret_history, secrets_secret_rollback, and target_exec. All data is resolved from the desktop app's unlocked local vault session. Legacy aliases secrets_find, secrets_add, and secrets_update remain supported." + } + }); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .header("mcp-session-id", session_id) + .body(Body::from(payload.to_string())) + .expect("build response") +} + +fn tool_definitions() -> Vec { + vec![ + json!({ + "name": "secrets_entry_find", + "description": "Find entries from the user's secrets vault.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": ["string", "null"] }, + "folder": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] } + } + } + }), + json!({ + "name": "secrets_entry_get", + "description": "Get one entry from the unlocked local vault by entry id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_entry_add", + "description": "Create a new entry and optionally include initial secrets.", + "inputSchema": { + "type": "object", + "properties": { + "folder": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": ["string", "null"] }, + "metadata": { "type": ["object", "null"] }, + "secrets": { + "type": ["array", "null"], + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "secret_type": { "type": ["string", "null"] }, + "value": { "type": "string" } + }, + "required": ["name", "value"] + } + } + }, + "required": ["folder", "name"] + } + }), + json!({ + "name": "secrets_entry_update", + "description": "Update an existing entry by id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "folder": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] }, + "metadata": { "type": ["object", "null"] } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_entry_delete", + "description": "Move an entry into recycle bin by id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_entry_restore", + "description": "Restore a deleted entry from recycle bin by id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_secret_add", + "description": "Create one secret under an existing entry.", + "inputSchema": { + "type": "object", + "properties": { + "entry_id": { "type": "string" }, + "name": { "type": "string" }, + "secret_type": { "type": ["string", "null"] }, + "value": { "type": "string" } + }, + "required": ["entry_id", "name", "value"] + } + }), + json!({ + "name": "secrets_secret_update", + "description": "Update one secret by id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": ["string", "null"] }, + "secret_type": { "type": ["string", "null"] }, + "value": { "type": ["string", "null"] } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_secret_delete", + "description": "Delete one secret by id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_secret_history", + "description": "List history snapshots for one secret by id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + }), + json!({ + "name": "secrets_secret_rollback", + "description": "Rollback one secret by id to a previous version or history id.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "version": { "type": ["integer", "null"] }, + "history_id": { "type": ["integer", "null"] } + }, + "required": ["id"] + } + }), + json!({ + "name": "target_exec", + "description": "Execute a local shell command with resolved TARGET_* environment variables from one entry.", + "inputSchema": { + "type": "object", + "properties": { + "target_ref": { "type": ["string", "null"] }, + "command": { "type": "string" }, + "timeout_secs": { "type": ["integer", "null"] }, + "working_dir": { "type": ["string", "null"] }, + "env_overrides": { "type": ["object", "null"] } + }, + "required": ["target_ref", "command"] + } + }), + json!({ + "name": "secrets_find", + "description": "Legacy alias for secrets_entry_find.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": ["string", "null"] }, + "folder": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] } + } + } + }), + json!({ + "name": "secrets_add", + "description": "Legacy alias for secrets_entry_add.", + "inputSchema": { + "type": "object", + "properties": { + "folder": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": ["string", "null"] }, + "metadata": { "type": ["object", "null"] }, + "secrets": { "type": ["array", "null"] } + }, + "required": ["folder", "name"] + } + }), + json!({ + "name": "secrets_update", + "description": "Legacy alias for secrets_entry_update.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "folder": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "type": { "type": ["string", "null"] }, + "metadata": { "type": ["object", "null"] } + }, + "required": ["id"] + } + }), + ] +} + +fn entry_detail_to_snapshot(detail: &EntryDetail) -> TargetSnapshot { + let metadata = detail + .metadata + .iter() + .map(|field| (field.label.clone(), Value::String(field.value.clone()))) + .collect(); + let secret_fields = detail + .secrets + .iter() + .map(|secret| crate::target::SecretFieldRef { + name: secret.name.clone(), + secret_type: Some(secret.secret_type.clone()), + }) + .collect(); + TargetSnapshot { + id: detail.id.clone(), + folder: detail.folder.clone(), + name: detail.name.clone(), + entry_type: Some(detail.cipher_type.clone()), + metadata, + secret_fields, + } +} + +fn revealed_secrets_to_env(secrets: &[SecretValueField]) -> HashMap { + secrets + .iter() + .map(|secret| (secret.name.clone(), Value::String(secret.value.clone()))) + .collect() +} + +async fn call_tool(state: &AppState, name: &str, arguments: Value) -> Result { + match name { + "secrets_find" | "secrets_entry_find" => { + let folder = arguments + .get("folder") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let query = arguments + .get("query") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let entry_type = arguments + .get("type") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let mut params = Vec::new(); + if let Some(folder) = folder { + params.push(("folder", folder)); + } + if let Some(query) = query { + params.push(("query", query)); + } + if let Some(entry_type) = entry_type { + params.push(("entry_type", entry_type)); + } + params.push(("deleted_only", "false".to_string())); + let entries = authorized_get(state, "/vault/entries", ¶ms) + .await? + .json::>() + .await + .context("failed to decode entries list")?; + Ok(json!({ + "entries": entries.into_iter().map(|entry| { + json!({ + "id": entry.id, + "folder": entry.folder, + "name": entry.name, + "type": entry.cipher_type + }) + }).collect::>() + })) + } + "secrets_entry_get" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let detail = fetch_entry_detail(state, id).await?; + let secrets = fetch_revealed_entry_secrets(state, id).await?; + Ok(entry_detail_payload(&detail, Some(&secrets))) + } + "secrets_add" | "secrets_entry_add" => { + let folder = arguments + .get("folder") + .and_then(Value::as_str) + .context("folder is required")?; + let name = arguments + .get("name") + .and_then(Value::as_str) + .context("name is required")?; + let entry_type = arguments + .get("type") + .and_then(Value::as_str) + .unwrap_or("entry"); + let metadata = arguments + .get("metadata") + .cloned() + .unwrap_or_else(|| json!({})); + let res = authorized_post( + state, + "/vault/entries", + &json!({ + "folder": folder, + "name": name, + "entry_type": entry_type, + "metadata": metadata, + "secrets": arguments.get("secrets").cloned().unwrap_or(Value::Null) + }), + ) + .await?; + Ok(res + .json::() + .await + .context("failed to decode create result")?) + } + "secrets_update" | "secrets_entry_update" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let body = json!({ + "folder": arguments.get("folder").cloned().unwrap_or(Value::Null), + "entry_type": arguments.get("type").cloned().unwrap_or(Value::Null), + "title": arguments.get("name").cloned().unwrap_or(Value::Null), + "metadata": arguments.get("metadata").cloned().unwrap_or(Value::Null) + }); + let res = authorized_patch(state, &format!("/vault/entries/{id}"), &body).await?; + Ok(res + .json::() + .await + .context("failed to decode update result")?) + } + "secrets_entry_delete" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let res = + authorized_post(state, &format!("/vault/entries/{id}/delete"), &json!({})).await?; + Ok(res + .json::() + .await + .context("failed to decode delete result")?) + } + "secrets_entry_restore" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let res = + authorized_post(state, &format!("/vault/entries/{id}/restore"), &json!({})).await?; + Ok(res + .json::() + .await + .context("failed to decode restore result")?) + } + "secrets_secret_add" => { + let entry_id = arguments + .get("entry_id") + .and_then(Value::as_str) + .context("entry_id is required")?; + let name = arguments + .get("name") + .and_then(Value::as_str) + .context("name is required")?; + let value = arguments + .get("value") + .and_then(Value::as_str) + .context("value is required")?; + let res = authorized_post( + state, + &format!("/vault/entries/{entry_id}/secrets"), + &json!({ + "name": name, + "secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null), + "value": value + }), + ) + .await?; + Ok(res + .json::() + .await + .context("failed to decode secret create result")?) + } + "secrets_secret_update" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let res = authorized_patch( + state, + &format!("/vault/secrets/{id}"), + &json!({ + "name": arguments.get("name").cloned().unwrap_or(Value::Null), + "secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null), + "value": arguments.get("value").cloned().unwrap_or(Value::Null) + }), + ) + .await?; + Ok(res + .json::() + .await + .context("failed to decode secret update result")?) + } + "secrets_secret_delete" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let res = + authorized_post(state, &format!("/vault/secrets/{id}/delete"), &json!({})).await?; + Ok(res + .json::() + .await + .context("failed to decode secret delete result")?) + } + "secrets_secret_history" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let history = authorized_get(state, &format!("/vault/secrets/{id}/history"), &[]) + .await? + .json::>() + .await + .context("failed to decode secret history")?; + Ok(json!({ + "history": history.into_iter().map(|item| { + json!({ + "history_id": item.history_id, + "secret_id": item.secret_id, + "name": item.name, + "type": item.secret_type, + "masked_value": item.masked_value, + "value": item.value, + "version": item.version, + "action": item.action, + "created_at": item.created_at + }) + }).collect::>() + })) + } + "secrets_secret_rollback" => { + let id = arguments + .get("id") + .and_then(Value::as_str) + .context("id is required")?; + let res = authorized_post( + state, + &format!("/vault/secrets/{id}/rollback"), + &json!({ + "version": arguments.get("version").cloned().unwrap_or(Value::Null), + "history_id": arguments.get("history_id").cloned().unwrap_or(Value::Null) + }), + ) + .await?; + Ok(res + .json::() + .await + .context("failed to decode secret rollback result")?) + } + "target_exec" => { + let input: TargetExecInput = + serde_json::from_value(arguments).context("invalid target_exec arguments")?; + let target_ref = input + .target_ref + .as_ref() + .context("target_ref is required")?; + let detail = fetch_entry_detail(state, target_ref).await?; + let secrets = fetch_revealed_entry_secrets(state, target_ref).await?; + let execution_target = build_execution_target( + &entry_detail_to_snapshot(&detail), + &revealed_secrets_to_env(&secrets), + )?; + let result = + execute_command(&input, &execution_target, input.timeout_secs.unwrap_or(30)) + .await?; + Ok(serde_json::to_value(result).context("failed to encode exec result")?) + } + other => Err(anyhow!("unsupported tool: {other}")), + } +} + +pub async fn handle_mcp(State(state): State, body: String) -> Response { + let request: JsonRpcRequest = match serde_json::from_str(&body) { + Ok(request) => request, + Err(err) => { + return json_response( + StatusCode::BAD_REQUEST, + json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32600, + "message": format!("invalid request: {err}") + } + }), + ); + } + }; + + match request.method.as_str() { + "initialize" => initialize_response(request.id), + "tools/list" => jsonrpc_result_response(request.id, json!({ "tools": tool_definitions() })), + "tools/call" => { + let name = request + .params + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let arguments = request + .params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + match call_tool(&state, name, arguments).await { + Ok(value) => tool_success_response(request.id, value), + Err(err) => tool_error_response(request.id, err.to_string()), + } + } + other => json_response( + StatusCode::OK, + json!({ + "jsonrpc": "2.0", + "id": request.id, + "error": { + "code": -32601, + "message": format!("method `{other}` not supported by secrets-desktop-daemon") + } + }), + ), + } +} + +pub async fn build_router() -> Result { + let session_base = std::env::var("SECRETS_DESKTOP_SESSION_URL") + .unwrap_or_else(|_| "http://127.0.0.1:9520".to_string()); + let state = AppState { + session_base, + client: reqwest::Client::new(), + }; + Ok(Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/mcp", any(handle_mcp)) + .with_state(state)) +} diff --git a/crates/desktop-daemon/src/main.rs b/crates/desktop-daemon/src/main.rs new file mode 100644 index 0000000..9f2dd8a --- /dev/null +++ b/crates/desktop-daemon/src/main.rs @@ -0,0 +1,26 @@ +use anyhow::{Context, Result}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "secrets_desktop_daemon=info".into()), + ) + .init(); + + let config = secrets_desktop_daemon::config::load_config()?; + let app = secrets_desktop_daemon::build_router().await?; + let listener = tokio::net::TcpListener::bind(&config.bind) + .await + .with_context(|| format!("failed to bind {}", config.bind))?; + + tracing::info!(bind = %config.bind, "secrets-desktop-daemon listening"); + axum::serve(listener, app) + .await + .context("daemon server error")?; + Ok(()) +} diff --git a/crates/secrets-mcp-local/src/target.rs b/crates/desktop-daemon/src/target.rs similarity index 64% rename from crates/secrets-mcp-local/src/target.rs rename to crates/desktop-daemon/src/target.rs index c1788b3..a73a65b 100644 --- a/crates/secrets-mcp-local/src/target.rs +++ b/crates/desktop-daemon/src/target.rs @@ -19,8 +19,6 @@ pub struct TargetSnapshot { #[serde(rename = "type")] pub entry_type: Option, #[serde(default)] - pub notes: Option, - #[serde(default)] pub metadata: Map, #[serde(default)] pub secret_fields: Vec, @@ -116,9 +114,6 @@ pub fn build_execution_target( if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) { env.insert("TARGET_TYPE".to_string(), entry_type.clone()); } - if let Some(notes) = snapshot.notes.as_ref().filter(|v| !v.is_empty()) { - env.insert("TARGET_NOTES".to_string(), notes.clone()); - } for (key, value) in &snapshot.metadata { if let Some(value) = stringify_value(value) { @@ -212,52 +207,126 @@ pub fn build_execution_target( #[cfg(test)] mod tests { use super::*; - use serde_json::json; - #[test] - fn build_execution_target_maps_common_aliases() { - let snapshot = TargetSnapshot { + fn build_snapshot() -> TargetSnapshot { + let mut metadata = Map::new(); + metadata.insert( + "host".to_string(), + Value::String("git.example.com".to_string()), + ); + metadata.insert("port".to_string(), Value::String("22".to_string())); + metadata.insert("username".to_string(), Value::String("deploy".to_string())); + metadata.insert( + "base_url".to_string(), + Value::String("https://api.example.com".to_string()), + ); + TargetSnapshot { id: "entry-1".to_string(), - folder: "refining".to_string(), - name: "hk_api_hub".to_string(), - entry_type: Some("server".to_string()), - notes: None, - metadata: serde_json::from_value(json!({ - "public_ip": "47.238.146.244", - "username": "ecs-user", - "base_url": "https://api.refining.dev" - })) - .unwrap(), + folder: "infra".to_string(), + name: "production".to_string(), + entry_type: Some("ssh_key".to_string()), + metadata, secret_fields: vec![ SecretFieldRef { name: "api_key".to_string(), - secret_type: None, + secret_type: Some("text".to_string()), }, SecretFieldRef { - name: "hk-20240726.pem".to_string(), + name: "token".to_string(), + secret_type: Some("text".to_string()), + }, + SecretFieldRef { + name: "ssh_key".to_string(), secret_type: Some("ssh-key".to_string()), }, ], - }; + } + } + + #[test] + fn derives_standard_target_env_keys() { + let snapshot = build_snapshot(); let secrets = HashMap::from([ - ("api_key".to_string(), json!("sk_test_123")), + ("api_key".to_string(), Value::String("ak-123".to_string())), + ("token".to_string(), Value::String("tok-456".to_string())), ( - "hk-20240726.pem".to_string(), - json!("-----BEGIN PRIVATE KEY-----"), + "ssh_key".to_string(), + Value::String("-----BEGIN KEY-----".to_string()), ), ]); - let target = build_execution_target(&snapshot, &secrets).unwrap(); - assert_eq!(target.env.get("TARGET_HOST").unwrap(), "47.238.146.244"); - assert_eq!(target.env.get("TARGET_USER").unwrap(), "ecs-user"); + let target = build_execution_target(&snapshot, &secrets).expect("build execution target"); + assert_eq!( - target.env.get("TARGET_BASE_URL").unwrap(), - "https://api.refining.dev" + target.env.get("TARGET_ENTRY_ID").map(String::as_str), + Some("entry-1") ); - assert_eq!(target.env.get("TARGET_API_KEY").unwrap(), "sk_test_123"); assert_eq!( - target.env.get("TARGET_SSH_KEY").unwrap(), - "-----BEGIN PRIVATE KEY-----" + target.env.get("TARGET_NAME").map(String::as_str), + Some("production") + ); + assert_eq!( + target.env.get("TARGET_FOLDER").map(String::as_str), + Some("infra") + ); + assert_eq!( + target.env.get("TARGET_TYPE").map(String::as_str), + Some("ssh_key") + ); + assert_eq!( + target.env.get("TARGET_HOST").map(String::as_str), + Some("git.example.com") + ); + assert_eq!( + target.env.get("TARGET_PORT").map(String::as_str), + Some("22") + ); + assert_eq!( + target.env.get("TARGET_USER").map(String::as_str), + Some("deploy") + ); + assert_eq!( + target.env.get("TARGET_BASE_URL").map(String::as_str), + Some("https://api.example.com") + ); + assert_eq!( + target.env.get("TARGET_API_KEY").map(String::as_str), + Some("ak-123") + ); + assert_eq!( + target.env.get("TARGET_TOKEN").map(String::as_str), + Some("tok-456") + ); + assert_eq!( + target.env.get("TARGET_SSH_KEY").map(String::as_str), + Some("-----BEGIN KEY-----") + ); + } + + #[test] + fn exports_sanitized_meta_and_secret_keys() { + let mut snapshot = build_snapshot(); + snapshot.metadata.insert( + "private-ip".to_string(), + Value::String("10.0.0.8".to_string()), + ); + let secrets = HashMap::from([( + "access key id".to_string(), + Value::String("access-1".to_string()), + )]); + + let target = build_execution_target(&snapshot, &secrets).expect("build execution target"); + + assert_eq!( + target.env.get("TARGET_META_PRIVATE_IP").map(String::as_str), + Some("10.0.0.8") + ); + assert_eq!( + target + .env + .get("TARGET_SECRET_ACCESS_KEY_ID") + .map(String::as_str), + Some("access-1") ); } } diff --git a/crates/desktop-daemon/src/vault_client.rs b/crates/desktop-daemon/src/vault_client.rs new file mode 100644 index 0000000..85214eb --- /dev/null +++ b/crates/desktop-daemon/src/vault_client.rs @@ -0,0 +1,168 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::AppState; + +#[derive(Debug, Deserialize)] +pub struct EntrySummary { + pub id: String, + pub folder: String, + #[serde(rename = "title")] + pub name: String, + #[serde(rename = "subtitle")] + pub cipher_type: String, +} + +#[derive(Debug, Deserialize)] +pub struct EntryDetail { + pub id: String, + #[serde(rename = "title")] + pub name: String, + pub folder: String, + #[serde(rename = "entry_type")] + pub cipher_type: String, + pub metadata: Vec, + pub secrets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct DetailField { + pub label: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +pub struct SecretField { + pub id: String, + pub name: String, + pub secret_type: String, + pub masked_value: String, + pub version: i64, +} + +#[derive(Debug, Deserialize)] +pub struct SecretValueField { + pub id: String, + pub name: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +pub struct SecretHistoryItem { + pub history_id: i64, + pub secret_id: String, + pub name: String, + pub secret_type: String, + pub masked_value: String, + pub value: String, + pub version: i64, + pub action: String, + pub created_at: String, +} + +pub async fn authorized_get( + state: &AppState, + path: &str, + query: &[(&str, String)], +) -> Result { + state + .client + .get(format!("{}{}", state.session_base, path)) + .query(query) + .send() + .await + .with_context(|| format!("desktop local vault unavailable: {path}"))? + .error_for_status() + .with_context(|| format!("desktop local vault requires sign-in and unlock: {path}")) +} + +pub async fn authorized_patch( + state: &AppState, + path: &str, + body: &Value, +) -> Result { + state + .client + .patch(format!("{}{}", state.session_base, path)) + .json(body) + .send() + .await + .with_context(|| format!("desktop local vault unavailable: {path}"))? + .error_for_status() + .with_context(|| format!("desktop local vault requires sign-in and unlock: {path}")) +} + +pub async fn authorized_post( + state: &AppState, + path: &str, + body: &Value, +) -> Result { + state + .client + .post(format!("{}{}", state.session_base, path)) + .json(body) + .send() + .await + .with_context(|| format!("desktop local vault unavailable: {path}"))? + .error_for_status() + .with_context(|| format!("desktop local vault requires sign-in and unlock: {path}")) +} + +pub async fn fetch_entry_detail(state: &AppState, entry_id: &str) -> Result { + authorized_get(state, &format!("/vault/entries/{entry_id}"), &[]) + .await? + .json::() + .await + .context("failed to decode entry detail") +} + +pub async fn fetch_revealed_entry_secrets( + state: &AppState, + entry_id: &str, +) -> Result> { + let detail = fetch_entry_detail(state, entry_id).await?; + let mut secrets = Vec::new(); + for secret in detail.secrets { + let item = authorized_get(state, &format!("/vault/secrets/{}/value", secret.id), &[]) + .await? + .json::() + .await + .context("failed to decode revealed secret value")?; + secrets.push(item); + } + Ok(secrets) +} + +pub fn entry_detail_payload(detail: &EntryDetail, revealed: Option<&[SecretValueField]>) -> Value { + let revealed_by_id: HashMap<&str, &SecretValueField> = revealed + .unwrap_or(&[]) + .iter() + .map(|secret| (secret.id.as_str(), secret)) + .collect(); + json!({ + "id": detail.id, + "folder": detail.folder, + "name": detail.name, + "type": detail.cipher_type, + "metadata": detail.metadata.iter().map(|field| { + json!({ + "label": field.label, + "value": field.value + }) + }).collect::>(), + "secrets": detail.secrets.iter().map(|secret| { + let revealed = revealed_by_id.get(secret.id.as_str()); + json!({ + "id": secret.id, + "name": secret.name, + "type": secret.secret_type, + "masked_value": secret.masked_value, + "value": revealed.map(|item| item.value.clone()), + "version": secret.version + }) + }).collect::>() + }) +} diff --git a/crates/device-auth/Cargo.toml b/crates/device-auth/Cargo.toml new file mode 100644 index 0000000..b90215c --- /dev/null +++ b/crates/device-auth/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "secrets-device-auth" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_device_auth" +path = "src/lib.rs" + +[dependencies] +anyhow.workspace = true +hex.workspace = true +rand.workspace = true +sha2.workspace = true +url.workspace = true +uuid.workspace = true diff --git a/crates/device-auth/src/lib.rs b/crates/device-auth/src/lib.rs new file mode 100644 index 0000000..c4804fa --- /dev/null +++ b/crates/device-auth/src/lib.rs @@ -0,0 +1,27 @@ +use anyhow::{Context, Result}; +use rand::{Rng, RngExt}; +use sha2::{Digest, Sha256}; +use url::Url; + +pub fn loopback_redirect_uri(port: u16) -> Result { + Url::parse(&format!("http://127.0.0.1:{port}/oauth/callback")) + .context("failed to build loopback redirect URI") +} + +pub fn new_device_fingerprint() -> String { + let mut bytes = [0_u8; 16]; + rand::rng().fill(&mut bytes); + hex::encode(bytes) +} + +pub fn new_device_login_token() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +pub fn hash_device_login_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..e91d6fc --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "secrets-domain" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_domain" +path = "src/lib.rs" + +[dependencies] +argon2 = "0.5.3" +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +uuid.workspace = true diff --git a/crates/domain/src/auth.rs b/crates/domain/src/auth.rs new file mode 100644 index 0000000..90bece2 --- /dev/null +++ b/crates/domain/src/auth.rs @@ -0,0 +1,68 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub email: Option, + pub name: String, + pub avatar_url: Option, + pub key_salt: Option>, + pub key_check: Option>, + pub key_params: Option, + pub key_version: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub id: Uuid, + pub user_id: Uuid, + pub display_name: String, + pub platform: String, + pub client_version: String, + pub device_fingerprint: String, + pub created_at: DateTime, + pub last_seen_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceLoginToken { + pub id: Uuid, + pub device_id: Uuid, + pub token_hash: String, + pub created_at: DateTime, + pub last_seen_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LoginMethod { + GoogleOauth, + DeviceToken, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LoginResult { + Success, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientLoginEvent { + pub id: i64, + pub user_id: Uuid, + pub device_id: Uuid, + pub device_name: String, + pub platform: String, + pub client_version: String, + pub ip_addr: Option, + pub forwarded_ip: Option, + pub login_method: LoginMethod, + pub login_result: LoginResult, + pub created_at: DateTime, +} diff --git a/crates/domain/src/cipher.rs b/crates/domain/src/cipher.rs new file mode 100644 index 0000000..9fe187b --- /dev/null +++ b/crates/domain/src/cipher.rs @@ -0,0 +1,138 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CipherType { + Login, + ApiKey, + SecureNote, + SshKey, + Identity, + Card, +} + +impl CipherType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Login => "login", + Self::ApiKey => "api_key", + Self::SecureNote => "secure_note", + Self::SshKey => "ssh_key", + Self::Identity => "identity", + Self::Card => "card", + } + } + + pub fn parse(input: &str) -> Self { + match input { + "login" => Self::Login, + "api_key" => Self::ApiKey, + "secure_note" => Self::SecureNote, + "ssh_key" => Self::SshKey, + "identity" => Self::Identity, + "card" => Self::Card, + _ => Self::SecureNote, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CustomField { + pub name: String, + pub value: Value, + #[serde(default)] + pub sensitive: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct LoginPayload { + #[serde(default)] + pub username: Option, + #[serde(default)] + pub uris: Vec, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub totp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ApiKeyPayload { + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub secret: Option, + #[serde(default)] + pub base_url: Option, + #[serde(default)] + pub host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SecureNotePayload { + #[serde(default)] + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SshKeyPayload { + #[serde(default)] + pub username: Option, + #[serde(default)] + pub host: Option, + #[serde(default)] + pub port: Option, + #[serde(default)] + pub private_key: Option, + #[serde(default)] + pub passphrase: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ItemPayload { + Login(LoginPayload), + ApiKey(ApiKeyPayload), + SecureNote(SecureNotePayload), + SshKey(SshKeyPayload), +} + +impl Default for ItemPayload { + fn default() -> Self { + Self::SecureNote(SecureNotePayload::default()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CipherView { + pub id: Uuid, + pub cipher_type: CipherType, + pub name: String, + pub folder: String, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub custom_fields: Vec, + #[serde(default)] + pub deleted_at: Option>, + pub revision_date: DateTime, + pub payload: ItemPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Cipher { + pub id: Uuid, + pub user_id: Uuid, + pub object_kind: String, + pub cipher_type: CipherType, + pub revision: i64, + pub cipher_version: i32, + pub ciphertext: Vec, + pub content_hash: String, + pub deleted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs new file mode 100644 index 0000000..c840b61 --- /dev/null +++ b/crates/domain/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DomainError { + #[error("resource not found")] + NotFound, + #[error("resource already exists")] + Conflict, + #[error("validation failed: {0}")] + Validation(String), + #[error("authentication failed")] + AuthenticationFailed, + #[error("decryption failed")] + DecryptionFailed, +} diff --git a/crates/domain/src/kdf.rs b/crates/domain/src/kdf.rs new file mode 100644 index 0000000..a3f5515 --- /dev/null +++ b/crates/domain/src/kdf.rs @@ -0,0 +1,37 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use serde::{Deserialize, Serialize}; + +use crate::DomainError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum KdfType { + Argon2id, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KdfConfig { + pub kdf_type: KdfType, + pub memory_kib: u32, + pub iterations: u32, + pub parallelism: u32, +} + +impl Default for KdfConfig { + fn default() -> Self { + Self { + kdf_type: KdfType::Argon2id, + memory_kib: 64 * 1024, + iterations: 3, + parallelism: 4, + } + } +} + +impl KdfConfig { + pub fn build_argon2(&self) -> Result, DomainError> { + let params = Params::new(self.memory_kib, self.iterations, self.parallelism, Some(32)) + .map_err(|err| DomainError::Validation(err.to_string()))?; + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..ac237a2 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,19 @@ +pub mod auth; +pub mod cipher; +pub mod error; +pub mod kdf; +pub mod sync; +pub mod vault_object; + +pub use auth::{ClientLoginEvent, Device, DeviceLoginToken, LoginMethod, LoginResult, User}; +pub use cipher::{ + ApiKeyPayload, Cipher, CipherType, CipherView, CustomField, ItemPayload, LoginPayload, + SecureNotePayload, SshKeyPayload, +}; +pub use error::DomainError; +pub use kdf::{KdfConfig, KdfType}; +pub use sync::{ + SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest, + SyncPushResponse, +}; +pub use vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultObjectKind, VaultTombstone}; diff --git a/crates/domain/src/sync.rs b/crates/domain/src/sync.rs new file mode 100644 index 0000000..2c9c1fd --- /dev/null +++ b/crates/domain/src/sync.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultTombstone}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SyncPullRequest { + pub cursor: Option, + pub limit: Option, + #[serde(default)] + pub include_deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SyncPullResponse { + pub server_revision: i64, + pub next_cursor: i64, + pub has_more: bool, + pub objects: Vec, + pub tombstones: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SyncPushRequest { + pub changes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SyncAcceptedChange { + pub change_id: uuid::Uuid, + pub object_id: uuid::Uuid, + pub revision: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SyncConflict { + pub change_id: uuid::Uuid, + pub object_id: uuid::Uuid, + pub reason: String, + pub server_object: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SyncPushResponse { + pub server_revision: i64, + pub accepted: Vec, + pub conflicts: Vec, +} diff --git a/crates/domain/src/vault_object.rs b/crates/domain/src/vault_object.rs new file mode 100644 index 0000000..5fdd7b5 --- /dev/null +++ b/crates/domain/src/vault_object.rs @@ -0,0 +1,48 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VaultObjectKind { + Cipher, +} + +impl VaultObjectKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Cipher => "cipher", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VaultObjectEnvelope { + pub object_id: Uuid, + pub object_kind: VaultObjectKind, + pub revision: i64, + pub cipher_version: i32, + pub ciphertext: Vec, + pub content_hash: String, + pub deleted_at: Option>, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VaultObjectChange { + pub change_id: Uuid, + pub object_id: Uuid, + pub object_kind: VaultObjectKind, + pub operation: String, + pub base_revision: Option, + pub cipher_version: Option, + pub ciphertext: Option>, + pub content_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VaultTombstone { + pub object_id: Uuid, + pub revision: i64, + pub deleted_at: DateTime, +} diff --git a/crates/infrastructure-db/Cargo.toml b/crates/infrastructure-db/Cargo.toml new file mode 100644 index 0000000..338eb78 --- /dev/null +++ b/crates/infrastructure-db/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "secrets-infrastructure-db" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "secrets_infrastructure_db" +path = "src/lib.rs" + +[dependencies] +anyhow.workspace = true +dotenvy.workspace = true +sqlx.workspace = true +tracing.workspace = true +uuid.workspace = true diff --git a/crates/infrastructure-db/src/lib.rs b/crates/infrastructure-db/src/lib.rs new file mode 100644 index 0000000..4794e6a --- /dev/null +++ b/crates/infrastructure-db/src/lib.rs @@ -0,0 +1,29 @@ +mod migrate; + +use anyhow::{Context, Result}; +use sqlx::PgPool; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use std::str::FromStr; + +pub use migrate::migrate_current_schema; + +pub fn load_database_url() -> Result { + std::env::var("SECRETS_DATABASE_URL") + .context("SECRETS_DATABASE_URL is required for current services") +} + +pub async fn create_pool(database_url: &str) -> Result { + let options = + PgConnectOptions::from_str(database_url).context("failed to parse SECRETS_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections( + std::env::var("SECRETS_DATABASE_POOL_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(10), + ) + .connect_with(options) + .await + .context("failed to connect to PostgreSQL")?; + Ok(pool) +} diff --git a/crates/infrastructure-db/src/migrate.rs b/crates/infrastructure-db/src/migrate.rs new file mode 100644 index 0000000..b2148d5 --- /dev/null +++ b/crates/infrastructure-db/src/migrate.rs @@ -0,0 +1,108 @@ +use anyhow::Result; +use sqlx::PgPool; + +pub async fn migrate_current_schema(pool: &PgPool) -> Result<()> { + sqlx::raw_sql( + r#" + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + email VARCHAR(256), + name VARCHAR(256) NOT NULL DEFAULT '', + avatar_url TEXT, + key_salt BYTEA, + key_check BYTEA, + key_params JSONB, + key_version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS oauth_accounts ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL, + provider_id VARCHAR(256) NOT NULL, + email VARCHAR(256), + name VARCHAR(256), + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(provider, provider_id), + UNIQUE(user_id, provider) + ); + + CREATE TABLE IF NOT EXISTS devices ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + display_name VARCHAR(256) NOT NULL, + platform VARCHAR(64) NOT NULL, + client_version VARCHAR(64) NOT NULL, + device_fingerprint TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id); + + CREATE TABLE IF NOT EXISTS device_login_tokens ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_device_login_tokens_device_id ON device_login_tokens(device_id); + + CREATE TABLE IF NOT EXISTS auth_events ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + device_name VARCHAR(256) NOT NULL, + platform VARCHAR(64) NOT NULL, + client_version VARCHAR(64) NOT NULL, + ip_addr TEXT, + forwarded_ip TEXT, + login_method VARCHAR(32) NOT NULL, + login_result VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_auth_events_user_id_created_at + ON auth_events(user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at + ON auth_events(device_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS vault_objects ( + object_id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + object_kind VARCHAR(32) NOT NULL, + revision BIGINT NOT NULL, + cipher_version INTEGER NOT NULL DEFAULT 1, + ciphertext BYTEA NOT NULL DEFAULT '\x', + content_hash TEXT NOT NULL DEFAULT '', + deleted_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_device UUID REFERENCES devices(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_vault_objects_user_revision + ON vault_objects(user_id, revision ASC); + CREATE INDEX IF NOT EXISTS idx_vault_objects_user_deleted + ON vault_objects(user_id, deleted_at); + + CREATE TABLE IF NOT EXISTS vault_object_revisions ( + object_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + revision BIGINT NOT NULL, + cipher_version INTEGER NOT NULL DEFAULT 1, + ciphertext BYTEA NOT NULL DEFAULT '\x', + content_hash TEXT NOT NULL DEFAULT '', + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (object_id, revision) + ); + CREATE INDEX IF NOT EXISTS idx_vault_object_revisions_user_revision + ON vault_object_revisions(user_id, revision ASC); + "#, + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/crates/secrets-core/Cargo.toml b/crates/secrets-core/Cargo.toml deleted file mode 100644 index f217f28..0000000 --- a/crates/secrets-core/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "secrets-core" -version = "0.1.0" -edition.workspace = true - -[lib] -name = "secrets_core" -path = "src/lib.rs" - -[dependencies] -aes-gcm.workspace = true -anyhow.workspace = true -thiserror.workspace = true -chrono.workspace = true -hex = "0.4" -rand.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_yaml.workspace = true -sqlx.workspace = true -toml.workspace = true -tokio.workspace = true -tracing.workspace = true -uuid.workspace = true - -[dev-dependencies] -tempfile = "3" diff --git a/crates/secrets-core/src/audit.rs b/crates/secrets-core/src/audit.rs deleted file mode 100644 index bccc671..0000000 --- a/crates/secrets-core/src/audit.rs +++ /dev/null @@ -1,88 +0,0 @@ -use serde_json::{Value, json}; -use sqlx::{PgPool, Postgres, Transaction}; -use uuid::Uuid; - -pub const ACTION_LOGIN: &str = "login"; -pub const FOLDER_AUTH: &str = "auth"; - -fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value { - json!({ - "provider": provider, - "client_ip": client_ip, - "user_agent": user_agent, - }) -} - -/// Write a login audit entry without requiring an explicit transaction. -pub async fn log_login( - pool: &PgPool, - entry_type: &str, - provider: &str, - user_id: Uuid, - client_ip: Option<&str>, - user_agent: Option<&str>, -) { - let detail = login_detail(provider, client_ip, user_agent); - let result: Result<_, sqlx::Error> = sqlx::query( - "INSERT INTO audit_log (user_id, action, folder, type, name, detail) \ - VALUES ($1, $2, $3, $4, $5, $6)", - ) - .bind(user_id) - .bind(ACTION_LOGIN) - .bind(FOLDER_AUTH) - .bind(entry_type) - .bind(provider) - .bind(&detail) - .execute(pool) - .await; - - if let Err(e) = result { - tracing::warn!(error = %e, entry_type, provider, "failed to write login audit log"); - } else { - tracing::debug!(entry_type, provider, ?user_id, "login audit logged"); - } -} - -/// Write an audit entry within an existing transaction. -pub async fn log_tx( - tx: &mut Transaction<'_, Postgres>, - user_id: Option, - action: &str, - folder: &str, - entry_type: &str, - name: &str, - detail: Value, -) { - let result: Result<_, sqlx::Error> = sqlx::query( - "INSERT INTO audit_log (user_id, action, folder, type, name, detail) \ - VALUES ($1, $2, $3, $4, $5, $6)", - ) - .bind(user_id) - .bind(action) - .bind(folder) - .bind(entry_type) - .bind(name) - .bind(&detail) - .execute(&mut **tx) - .await; - - if let Err(e) = result { - tracing::warn!(error = %e, "failed to write audit log"); - } else { - tracing::debug!(action, folder, entry_type, name, "audit logged"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn login_detail_includes_expected_fields() { - let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0")); - - assert_eq!(detail["provider"], "google"); - assert_eq!(detail["client_ip"], "127.0.0.1"); - assert_eq!(detail["user_agent"], "Mozilla/5.0"); - } -} diff --git a/crates/secrets-core/src/config.rs b/crates/secrets-core/src/config.rs deleted file mode 100644 index dbcd003..0000000 --- a/crates/secrets-core/src/config.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use sqlx::postgres::PgSslMode; - -#[derive(Debug, Clone)] -pub struct DatabaseConfig { - pub url: String, - pub ssl_mode: Option, - pub ssl_root_cert: Option, -} - -/// Resolve database URL from environment. -/// Priority: `SECRETS_DATABASE_URL` env var → error. -pub fn resolve_db_url(override_url: &str) -> Result { - if !override_url.is_empty() { - return Ok(override_url.to_string()); - } - - if let Ok(url) = std::env::var("SECRETS_DATABASE_URL") - && !url.is_empty() - { - return Ok(url); - } - - anyhow::bail!( - "Database not configured. Set the SECRETS_DATABASE_URL environment variable.\n\ - Example: SECRETS_DATABASE_URL=postgres://user:pass@host:port/dbname" - ) -} - -fn env_var_non_empty(name: &str) -> Option { - std::env::var(name) - .ok() - .filter(|value| !value.trim().is_empty()) -} - -fn parse_ssl_mode_from_env() -> Result> { - let Some(mode) = env_var_non_empty("SECRETS_DATABASE_SSL_MODE") else { - return Ok(None); - }; - - let parsed = mode.parse::().with_context(|| { - format!( - "Invalid SECRETS_DATABASE_SSL_MODE='{mode}'. Use one of: disable, allow, prefer, require, verify-ca, verify-full." - ) - })?; - Ok(Some(parsed)) -} - -fn resolve_ssl_root_cert_from_env() -> Result> { - let Some(path) = env_var_non_empty("SECRETS_DATABASE_SSL_ROOT_CERT") else { - return Ok(None); - }; - let path = PathBuf::from(path); - if !path.exists() { - anyhow::bail!( - "SECRETS_DATABASE_SSL_ROOT_CERT points to a missing file: {}", - path.display() - ); - } - Ok(Some(path)) -} - -pub fn resolve_db_config(override_url: &str) -> Result { - Ok(DatabaseConfig { - url: resolve_db_url(override_url)?, - ssl_mode: parse_ssl_mode_from_env()?, - ssl_root_cert: resolve_ssl_root_cert_from_env()?, - }) -} diff --git a/crates/secrets-core/src/crypto.rs b/crates/secrets-core/src/crypto.rs deleted file mode 100644 index 95f225d..0000000 --- a/crates/secrets-core/src/crypto.rs +++ /dev/null @@ -1,128 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, Key, Nonce, - aead::{Aead, AeadCore, KeyInit, OsRng}, -}; -use anyhow::{Context, Result, bail}; -use serde_json::Value; - -use crate::error::AppError; - -const NONCE_LEN: usize = 12; - -// ─── AES-256-GCM encrypt / decrypt ─────────────────────────────────────────── - -/// Encrypt plaintext bytes with AES-256-GCM. -/// Returns `nonce (12 B) || ciphertext+tag`. -pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result> { - let key = Key::::from_slice(master_key); - let cipher = Aes256Gcm::new(key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt(&nonce, plaintext) - .map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?; - let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len()); - out.extend_from_slice(&nonce); - out.extend_from_slice(&ciphertext); - Ok(out) -} - -/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM. -pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result> { - if data.len() < NONCE_LEN { - bail!( - "encrypted data too short ({}B); possibly corrupted", - data.len() - ); - } - let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN); - let key = Key::::from_slice(master_key); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(nonce_bytes); - cipher - .decrypt(nonce, ciphertext) - .map_err(|_| AppError::DecryptionFailed.into()) -} - -// ─── JSON helpers ───────────────────────────────────────────────────────────── - -/// Serialize a JSON Value and encrypt it. Returns the encrypted blob. -pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result> { - let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?; - encrypt(master_key, &bytes) -} - -/// Decrypt an encrypted blob and deserialize it as a JSON Value. -pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result { - let bytes = decrypt(master_key, data)?; - serde_json::from_slice(&bytes).context("deserialize decrypted JSON") -} - -// ─── Client-supplied key extraction ────────────────────────────────────────── - -/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key. -pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> { - let bytes = ::hex::decode(hex_str.trim())?; - if bytes.len() != 32 { - bail!( - "X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes", - bytes.len() - ); - } - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - Ok(key) -} - -// ─── Public hex helpers ─────────────────────────────────────────────────────── - -pub mod hex { - use anyhow::Result; - - pub fn encode_hex(bytes: &[u8]) -> String { - ::hex::encode(bytes) - } - - pub fn decode_hex(s: &str) -> Result> { - Ok(::hex::decode(s.trim())?) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn roundtrip_encrypt_decrypt() { - let key = [0x42u8; 32]; - let plaintext = b"hello world"; - let enc = encrypt(&key, plaintext).unwrap(); - let dec = decrypt(&key, &enc).unwrap(); - assert_eq!(dec, plaintext); - } - - #[test] - fn encrypt_produces_different_ciphertexts() { - let key = [0x42u8; 32]; - let plaintext = b"hello world"; - let enc1 = encrypt(&key, plaintext).unwrap(); - let enc2 = encrypt(&key, plaintext).unwrap(); - assert_ne!(enc1, enc2); - } - - #[test] - fn wrong_key_fails_decryption() { - let key1 = [0x42u8; 32]; - let key2 = [0x43u8; 32]; - let enc = encrypt(&key1, b"secret").unwrap(); - assert!(decrypt(&key2, &enc).is_err()); - } - - #[test] - fn json_roundtrip() { - let key = [0x42u8; 32]; - let value = serde_json::json!({"token": "abc123", "password": "hunter2"}); - let enc = encrypt_json(&key, &value).unwrap(); - let dec = decrypt_json(&key, &enc).unwrap(); - assert_eq!(dec, value); - } -} diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs deleted file mode 100644 index fac5583..0000000 --- a/crates/secrets-core/src/db.rs +++ /dev/null @@ -1,657 +0,0 @@ -use std::str::FromStr; - -use anyhow::{Context, Result}; -use serde_json::{Map, Value}; -use sqlx::PgPool; -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; - -use crate::config::DatabaseConfig; - -fn build_connect_options(config: &DatabaseConfig) -> Result { - let mut options = PgConnectOptions::from_str(&config.url) - .with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?; - - if let Some(mode) = config.ssl_mode { - options = options.ssl_mode(mode); - } - if let Some(path) = &config.ssl_root_cert { - options = options.ssl_root_cert(path); - } - - Ok(options) -} - -pub async fn create_pool(config: &DatabaseConfig) -> Result { - tracing::debug!("connecting to database"); - let connect_options = build_connect_options(config)?; - - // Connection pool configuration from environment - let max_connections = std::env::var("SECRETS_DATABASE_POOL_SIZE") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(10); - - let acquire_timeout_secs = std::env::var("SECRETS_DATABASE_ACQUIRE_TIMEOUT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(5); - - let pool = PgPoolOptions::new() - .max_connections(max_connections) - .acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs)) - .max_lifetime(std::time::Duration::from_secs(1800)) // 30 minutes - .idle_timeout(std::time::Duration::from_secs(600)) // 10 minutes - .connect_with(connect_options) - .await?; - - tracing::debug!( - max_connections, - acquire_timeout_secs, - "database connection established" - ); - Ok(pool) -} - -pub async fn migrate(pool: &PgPool) -> Result<()> { - tracing::debug!("running migrations"); - sqlx::raw_sql( - r#" - -- ── entries: top-level entities ───────────────────────────────────────── - CREATE TABLE IF NOT EXISTS entries ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID, - folder VARCHAR(128) NOT NULL DEFAULT '', - type VARCHAR(64) NOT NULL DEFAULT '', - name VARCHAR(256) NOT NULL, - notes TEXT NOT NULL DEFAULT '', - tags TEXT[] NOT NULL DEFAULT '{}', - metadata JSONB NOT NULL DEFAULT '{}', - version BIGINT NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ - ); - - -- Legacy unique constraint without user_id (single-user mode) - -- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support. - CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy - ON entries(folder, name) - WHERE user_id IS NULL; - - -- Multi-user unique constraint - CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user - ON entries(user_id, folder, name) - WHERE user_id IS NOT NULL; - - CREATE INDEX IF NOT EXISTS idx_entries_folder ON entries(folder) WHERE folder <> ''; - CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type) WHERE type <> ''; - CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id) WHERE user_id IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags); - CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops); - - -- ── secrets: one row per encrypted field ───────────────────────────────── - CREATE TABLE IF NOT EXISTS secrets ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID, - name VARCHAR(256) NOT NULL, - type VARCHAR(64) NOT NULL DEFAULT 'text', - encrypted BYTEA NOT NULL DEFAULT '\x', - version BIGINT NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name - ON secrets(user_id, name) WHERE user_id IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name); - CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type); - - -- ── entry_secrets: N:N relation ──────────────────────────────────────────── - CREATE TABLE IF NOT EXISTS entry_secrets ( - entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE, - sort_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY(entry_id, secret_id) - ); - CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id); - - -- ── entry_relations: parent-child links between entries ────────────────── - CREATE TABLE IF NOT EXISTS entry_relations ( - parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY(parent_entry_id, child_entry_id), - CHECK (parent_entry_id <> child_entry_id) - ); - CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id); - CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id); - - -- ── audit_log: append-only operation log ───────────────────────────────── - CREATE TABLE IF NOT EXISTS audit_log ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - user_id UUID, - action VARCHAR(32) NOT NULL, - folder VARCHAR(128) NOT NULL DEFAULT '', - type VARCHAR(64) NOT NULL DEFAULT '', - name VARCHAR(256) NOT NULL, - detail JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type ON audit_log(folder, type); - CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL; - - -- ── entries_history ─────────────────────────────────────────────────────── - CREATE TABLE IF NOT EXISTS entries_history ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - entry_id UUID NOT NULL, - folder VARCHAR(128) NOT NULL DEFAULT '', - type VARCHAR(64) NOT NULL DEFAULT '', - name VARCHAR(256) NOT NULL, - version BIGINT NOT NULL, - action VARCHAR(16) NOT NULL, - tags TEXT[] NOT NULL DEFAULT '{}', - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id - ON entries_history(entry_id, version DESC); - CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name - ON entries_history(folder, type, name, version DESC); - - -- Backfill: add user_id to entries_history for multi-tenant isolation - ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID; - CREATE INDEX IF NOT EXISTS idx_entries_history_user_id - ON entries_history(user_id) WHERE user_id IS NOT NULL; - ALTER TABLE entries_history DROP COLUMN IF EXISTS actor; - - -- Backfill: add notes to entries if not present (fresh installs already have it) - ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT ''; - ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - - -- ── secrets_history: field-level snapshot ──────────────────────────────── - CREATE TABLE IF NOT EXISTS secrets_history ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - secret_id UUID NOT NULL, - name VARCHAR(256) NOT NULL, - encrypted BYTEA NOT NULL DEFAULT '\x', - action VARCHAR(16) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id - ON secrets_history(secret_id); - - -- Drop redundant actor column (derivable via entries_history JOIN) - ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; - - -- ── users ───────────────────────────────────────────────────────────────── - CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - email VARCHAR(256), - name VARCHAR(256) NOT NULL DEFAULT '', - avatar_url TEXT, - key_salt BYTEA, - key_check BYTEA, - key_params JSONB, - api_key TEXT UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - -- ── oauth_accounts: per-provider identity links ─────────────────────────── - CREATE TABLE IF NOT EXISTS oauth_accounts ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider VARCHAR(32) NOT NULL, - provider_id VARCHAR(256) NOT NULL, - email VARCHAR(256), - name VARCHAR(256), - avatar_url TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(provider, provider_id) - ); - - CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider - ON oauth_accounts(user_id, provider); - - -- ── local_mcp_bind_sessions: short-lived browser approval state ────────── - CREATE TABLE IF NOT EXISTS local_mcp_bind_sessions ( - bind_id TEXT PRIMARY KEY, - device_code TEXT NOT NULL, - user_id UUID, - approved BOOLEAN NOT NULL DEFAULT FALSE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_expires_at - ON local_mcp_bind_sessions(expires_at); - CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_user_id - ON local_mcp_bind_sessions(user_id) WHERE user_id IS NOT NULL; - - -- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL) - DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_user_id' - ) THEN - ALTER TABLE entries - ADD CONSTRAINT fk_entries_user_id - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; - END IF; - END $$; - - DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_history_user_id' - ) THEN - ALTER TABLE entries_history - ADD CONSTRAINT fk_entries_history_user_id - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; - END IF; - END $$; - - DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id' - ) THEN - ALTER TABLE secrets - ADD CONSTRAINT fk_secrets_user_id - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; - END IF; - END $$; - - DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id' - ) THEN - ALTER TABLE audit_log - ADD CONSTRAINT fk_audit_log_user_id - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; - END IF; - END $$; - "#, - ) - .execute(pool) - .await?; - migrate_schema(pool).await?; - restore_plaintext_api_keys(pool).await?; - - tracing::debug!("migrations complete"); - Ok(()) -} - -/// Idempotent schema migration: rename namespace→folder, kind→type in existing databases. -async fn migrate_schema(pool: &PgPool) -> Result<()> { - sqlx::raw_sql( - r#" - -- ── entries: rename namespace→folder, kind→type ────────────────────────── - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries' AND column_name = 'namespace' - ) THEN - ALTER TABLE entries RENAME COLUMN namespace TO folder; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries' AND column_name = 'kind' - ) THEN - ALTER TABLE entries RENAME COLUMN kind TO type; - END IF; - END $$; - - -- ── audit_log: rename namespace→folder, kind→type ──────────────────────── - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'audit_log' AND column_name = 'namespace' - ) THEN - ALTER TABLE audit_log RENAME COLUMN namespace TO folder; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'audit_log' AND column_name = 'kind' - ) THEN - ALTER TABLE audit_log RENAME COLUMN kind TO type; - END IF; - END $$; - - -- ── entries_history: rename namespace→folder, kind→type ────────────────── - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries_history' AND column_name = 'namespace' - ) THEN - ALTER TABLE entries_history RENAME COLUMN namespace TO folder; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries_history' AND column_name = 'kind' - ) THEN - ALTER TABLE entries_history RENAME COLUMN kind TO type; - END IF; - END $$; - - -- ── Set empty defaults for new folder/type columns ──────────────────────── - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries' AND column_name = 'folder' - ) THEN - UPDATE entries SET folder = '' WHERE folder IS NULL; - ALTER TABLE entries ALTER COLUMN folder SET NOT NULL; - ALTER TABLE entries ALTER COLUMN folder SET DEFAULT ''; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries' AND column_name = 'type' - ) THEN - UPDATE entries SET type = '' WHERE type IS NULL; - ALTER TABLE entries ALTER COLUMN type SET NOT NULL; - ALTER TABLE entries ALTER COLUMN type SET DEFAULT ''; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'audit_log' AND column_name = 'folder' - ) THEN - UPDATE audit_log SET folder = '' WHERE folder IS NULL; - ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL; - ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT ''; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'audit_log' AND column_name = 'type' - ) THEN - UPDATE audit_log SET type = '' WHERE type IS NULL; - ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL; - ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT ''; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries_history' AND column_name = 'folder' - ) THEN - UPDATE entries_history SET folder = '' WHERE folder IS NULL; - ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL; - ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT ''; - END IF; - END $$; - - DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'entries_history' AND column_name = 'type' - ) THEN - UPDATE entries_history SET type = '' WHERE type IS NULL; - ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL; - ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT ''; - END IF; - END $$; - - -- ── Rebuild unique indexes on entries: folder is now part of the key ──────── - -- (user_id, folder, name) allows same name in different folders. - DROP INDEX IF EXISTS idx_entries_unique_legacy; - DROP INDEX IF EXISTS idx_entries_unique_user; - - CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy - ON entries(folder, name) - WHERE user_id IS NULL AND deleted_at IS NULL; - - CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user - ON entries(user_id, folder, name) - WHERE user_id IS NOT NULL AND deleted_at IS NULL; - - -- ── Replace old namespace/kind indexes ──────────────────────────────────── - DROP INDEX IF EXISTS idx_entries_namespace; - DROP INDEX IF EXISTS idx_entries_kind; - DROP INDEX IF EXISTS idx_audit_log_ns_kind; - DROP INDEX IF EXISTS idx_entries_history_ns_kind_name; - - CREATE INDEX IF NOT EXISTS idx_entries_folder - ON entries(folder) WHERE folder <> ''; - CREATE INDEX IF NOT EXISTS idx_entries_type - ON entries(type) WHERE type <> ''; - CREATE INDEX IF NOT EXISTS idx_entries_deleted_at - ON entries(deleted_at) WHERE deleted_at IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type - ON audit_log(folder, type); - CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name - ON entries_history(folder, type, name, version DESC); - - -- ── Drop legacy actor columns ───────────────────────────────────────────── - ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; - ALTER TABLE audit_log DROP COLUMN IF EXISTS actor; - - -- ── key_version: incremented on passphrase change to invalidate other sessions ── - ALTER TABLE users ADD COLUMN IF NOT EXISTS key_version BIGINT NOT NULL DEFAULT 0; - "#, - ) - .execute(pool) - .await?; - Ok(()) -} - -async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> { - let has_users_api_key: bool = sqlx::query_scalar( - "SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'users' - AND column_name = 'api_key' - )", - ) - .fetch_one(pool) - .await?; - - if !has_users_api_key { - sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT") - .execute(pool) - .await?; - sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL") - .execute(pool) - .await?; - } - - let has_api_keys_table: bool = sqlx::query_scalar( - "SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'api_keys' - )", - ) - .fetch_one(pool) - .await?; - - if !has_api_keys_table { - return Ok(()); - } - - #[derive(sqlx::FromRow)] - struct UserWithoutKey { - id: uuid::Uuid, - } - - let users_without_key: Vec = - sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)") - .fetch_all(pool) - .await?; - - for user in users_without_key { - let new_key = crate::service::api_key::generate_api_key(); - sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2") - .bind(&new_key) - .bind(user.id) - .execute(pool) - .await?; - } - - sqlx::query("DROP TABLE IF EXISTS api_keys") - .execute(pool) - .await?; - - Ok(()) -} - -// ── Entry-level history snapshot ───────────────────────────────────────────── - -pub struct EntrySnapshotParams<'a> { - pub entry_id: uuid::Uuid, - pub user_id: Option, - pub folder: &'a str, - pub entry_type: &'a str, - pub name: &'a str, - pub version: i64, - pub action: &'a str, - pub tags: &'a [String], - pub metadata: &'a Value, -} - -pub async fn snapshot_entry_history( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - p: EntrySnapshotParams<'_>, -) -> Result<()> { - sqlx::query( - "INSERT INTO entries_history \ - (entry_id, folder, type, name, version, action, tags, metadata, user_id) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - ) - .bind(p.entry_id) - .bind(p.folder) - .bind(p.entry_type) - .bind(p.name) - .bind(p.version) - .bind(p.action) - .bind(p.tags) - .bind(p.metadata) - .bind(p.user_id) - .execute(&mut **tx) - .await?; - Ok(()) -} - -// ── Secret field-level history snapshot ────────────────────────────────────── - -pub struct SecretSnapshotParams<'a> { - pub secret_id: uuid::Uuid, - pub name: &'a str, - pub encrypted: &'a [u8], - pub action: &'a str, -} - -pub async fn snapshot_secret_history( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - p: SecretSnapshotParams<'_>, -) -> Result<()> { - sqlx::query( - "INSERT INTO secrets_history \ - (secret_id, name, encrypted, action) \ - VALUES ($1, $2, $3, $4)", - ) - .bind(p.secret_id) - .bind(p.name) - .bind(p.encrypted) - .bind(p.action) - .execute(&mut **tx) - .await?; - Ok(()) -} - -pub const ENTRY_HISTORY_SECRETS_KEY: &str = "__secrets_snapshot_v1"; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct EntrySecretSnapshot { - pub name: String, - #[serde(rename = "type")] - pub secret_type: String, - pub encrypted_hex: String, -} - -pub async fn metadata_with_secret_snapshot( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - entry_id: uuid::Uuid, - metadata: &Value, -) -> Result { - #[derive(sqlx::FromRow)] - struct Row { - name: String, - #[sqlx(rename = "type")] - secret_type: String, - encrypted: Vec, - } - - let rows: Vec = sqlx::query_as( - "SELECT s.name, s.type, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1 \ - ORDER BY s.name ASC", - ) - .bind(entry_id) - .fetch_all(&mut **tx) - .await?; - - let snapshots: Vec = rows - .into_iter() - .map(|r| EntrySecretSnapshot { - name: r.name, - secret_type: r.secret_type, - encrypted_hex: ::hex::encode(r.encrypted), - }) - .collect(); - - let mut merged = match metadata.clone() { - Value::Object(obj) => obj, - _ => Map::new(), - }; - merged.insert( - ENTRY_HISTORY_SECRETS_KEY.to_string(), - serde_json::to_value(snapshots)?, - ); - Ok(Value::Object(merged)) -} - -pub fn strip_secret_snapshot_from_metadata(metadata: &Value) -> Value { - let mut m = match metadata.clone() { - Value::Object(obj) => obj, - _ => return metadata.clone(), - }; - m.remove(ENTRY_HISTORY_SECRETS_KEY); - Value::Object(m) -} - -pub fn entry_secret_snapshot_from_metadata(metadata: &Value) -> Option> { - let Value::Object(map) = metadata else { - return None; - }; - let raw = map.get(ENTRY_HISTORY_SECRETS_KEY)?; - serde_json::from_value(raw.clone()).ok() -} - -// ── DB helpers ──────────────────────────────────────────────────────────────── diff --git a/crates/secrets-core/src/error.rs b/crates/secrets-core/src/error.rs deleted file mode 100644 index 699e52a..0000000 --- a/crates/secrets-core/src/error.rs +++ /dev/null @@ -1,172 +0,0 @@ -use sqlx::error::DatabaseError; - -/// Structured business errors for the secrets service. -/// -/// These replace ad-hoc `anyhow` strings for expected failure modes, -/// allowing MCP and Web layers to map to appropriate protocol-level errors. -#[derive(Debug, thiserror::Error)] -pub enum AppError { - #[error("A secret with the name '{secret_name}' already exists for this user")] - ConflictSecretName { secret_name: String }, - - #[error("An entry with folder='{folder}' and name='{name}' already exists")] - ConflictEntryName { folder: String, name: String }, - - #[error("Entry not found")] - NotFoundEntry, - - #[error("User not found")] - NotFoundUser, - - #[error("Secret not found")] - NotFoundSecret, - - #[error("Authentication failed")] - AuthenticationFailed, - - #[error("Unauthorized: insufficient permissions")] - Unauthorized, - - #[error("Validation failed: {message}")] - Validation { message: String }, - - #[error("Concurrent modification detected")] - ConcurrentModification, - - #[error("Decryption failed — the encryption key may be incorrect")] - DecryptionFailed, - - #[error("Encryption key not set — user must set passphrase first")] - EncryptionKeyNotSet, - - #[error(transparent)] - Internal(#[from] anyhow::Error), -} - -impl AppError { - /// Try to convert a sqlx database error into a structured `AppError`. - /// - /// The caller should provide the context (which table was being written, - /// what values were being inserted) so we can produce a meaningful error. - pub fn from_db_error(err: sqlx::Error, ctx: DbErrorContext<'_>) -> Self { - if let sqlx::Error::Database(ref db_err) = err - && db_err.code().as_deref() == Some("23505") - { - return Self::from_unique_violation(db_err.as_ref(), ctx); - } - AppError::Internal(err.into()) - } - - fn from_unique_violation(db_err: &dyn DatabaseError, ctx: DbErrorContext<'_>) -> Self { - let constraint = db_err.constraint(); - - match constraint { - Some("idx_secrets_unique_user_name") => AppError::ConflictSecretName { - secret_name: ctx.secret_name.unwrap_or("unknown").to_string(), - }, - Some("idx_entries_unique_user") | Some("idx_entries_unique_legacy") => { - AppError::ConflictEntryName { - folder: ctx.folder.unwrap_or("").to_string(), - name: ctx.name.unwrap_or("unknown").to_string(), - } - } - _ => { - // Fall back to message-based detection for unnamed constraints - let msg = db_err.message(); - if msg.contains("secrets") { - AppError::ConflictSecretName { - secret_name: ctx.secret_name.unwrap_or("unknown").to_string(), - } - } else { - AppError::ConflictEntryName { - folder: ctx.folder.unwrap_or("").to_string(), - name: ctx.name.unwrap_or("unknown").to_string(), - } - } - } - } - } -} - -/// Context hints used when converting a database error to `AppError`. -#[derive(Debug, Default, Clone, Copy)] -pub struct DbErrorContext<'a> { - pub secret_name: Option<&'a str>, - pub folder: Option<&'a str>, - pub name: Option<&'a str>, -} - -impl<'a> DbErrorContext<'a> { - pub fn secret_name(name: &'a str) -> Self { - Self { - secret_name: Some(name), - ..Default::default() - } - } - - pub fn entry(folder: &'a str, name: &'a str) -> Self { - Self { - folder: Some(folder), - name: Some(name), - ..Default::default() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn app_error_display_messages() { - let err = AppError::ConflictSecretName { - secret_name: "token".to_string(), - }; - assert!(err.to_string().contains("token")); - - let err = AppError::ConflictEntryName { - folder: "refining".to_string(), - name: "gitea".to_string(), - }; - assert!(err.to_string().contains("refining")); - assert!(err.to_string().contains("gitea")); - - let err = AppError::NotFoundEntry; - assert_eq!(err.to_string(), "Entry not found"); - - let err = AppError::NotFoundUser; - assert_eq!(err.to_string(), "User not found"); - - let err = AppError::NotFoundSecret; - assert_eq!(err.to_string(), "Secret not found"); - - let err = AppError::AuthenticationFailed; - assert_eq!(err.to_string(), "Authentication failed"); - - let err = AppError::Unauthorized; - assert!(err.to_string().contains("Unauthorized")); - - let err = AppError::Validation { - message: "too long".to_string(), - }; - assert!(err.to_string().contains("too long")); - - let err = AppError::ConcurrentModification; - assert!(err.to_string().contains("Concurrent modification")); - - let err = AppError::EncryptionKeyNotSet; - assert!(err.to_string().contains("Encryption key not set")); - } - - #[test] - fn db_error_context_helpers() { - let ctx = DbErrorContext::secret_name("my_key"); - assert_eq!(ctx.secret_name, Some("my_key")); - assert!(ctx.folder.is_none()); - - let ctx = DbErrorContext::entry("prod", "db-creds"); - assert_eq!(ctx.folder, Some("prod")); - assert_eq!(ctx.name, Some("db-creds")); - assert!(ctx.secret_name.is_none()); - } -} diff --git a/crates/secrets-core/src/lib.rs b/crates/secrets-core/src/lib.rs deleted file mode 100644 index b38e1a3..0000000 --- a/crates/secrets-core/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod audit; -pub mod config; -pub mod crypto; -pub mod db; -pub mod error; -pub mod models; -pub mod service; -pub mod taxonomy; diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs deleted file mode 100644 index cab5e29..0000000 --- a/crates/secrets-core/src/models.rs +++ /dev/null @@ -1,357 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use uuid::Uuid; - -/// A top-level entry (server, service, account, person, …). -/// Sensitive fields are stored separately in `secrets`. -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct Entry { - pub id: Uuid, - pub user_id: Option, - pub folder: String, - #[serde(rename = "type")] - #[sqlx(rename = "type")] - pub entry_type: String, - pub name: String, - pub notes: String, - pub tags: Vec, - pub metadata: Value, - pub version: i64, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// A single encrypted field belonging to an Entry. -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct SecretField { - pub id: Uuid, - pub user_id: Option, - pub name: String, - #[serde(rename = "type")] - #[sqlx(rename = "type")] - pub secret_type: String, - /// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag - pub encrypted: Vec, - pub version: i64, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -// ── Internal query row types (shared across commands) ───────────────────────── - -/// Minimal entry row fetched for write operations (add / update / delete / rollback). -#[derive(Debug, sqlx::FromRow)] -pub struct EntryRow { - pub id: Uuid, - pub version: i64, - pub folder: String, - #[sqlx(rename = "type")] - pub entry_type: String, - pub tags: Vec, - pub metadata: Value, - pub notes: String, - pub name: String, -} - -/// Entry row including `name` (used for id-scoped web / service updates). -#[derive(Debug, sqlx::FromRow)] -pub struct EntryWriteRow { - pub id: Uuid, - pub version: i64, - pub folder: String, - #[sqlx(rename = "type")] - pub entry_type: String, - pub name: String, - pub tags: Vec, - pub metadata: Value, - pub notes: String, - pub deleted_at: Option>, -} - -impl From<&EntryWriteRow> for EntryRow { - fn from(r: &EntryWriteRow) -> Self { - EntryRow { - id: r.id, - version: r.version, - folder: r.folder.clone(), - entry_type: r.entry_type.clone(), - tags: r.tags.clone(), - metadata: r.metadata.clone(), - notes: r.notes.clone(), - name: r.name.clone(), - } - } -} - -/// Minimal secret field row fetched before snapshots or cascade deletes. -#[derive(Debug, sqlx::FromRow)] -pub struct SecretFieldRow { - pub id: Uuid, - pub name: String, - pub encrypted: Vec, -} - -// ── Export / Import types ────────────────────────────────────────────────────── - -/// Supported file formats for export/import. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ExportFormat { - Json, - Toml, - Yaml, -} - -impl std::str::FromStr for ExportFormat { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "json" => Ok(Self::Json), - "toml" => Ok(Self::Toml), - "yaml" | "yml" => Ok(Self::Yaml), - other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other), - } - } -} - -impl ExportFormat { - /// Infer format from file extension (.json / .toml / .yaml / .yml). - pub fn from_extension(path: &str) -> anyhow::Result { - let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); - ext.parse().map_err(|_| { - anyhow::anyhow!( - "Cannot infer format from extension '.{}'. Use --format json|toml|yaml", - ext - ) - }) - } - - /// Serialize ExportData to a string in this format. - pub fn serialize(&self, data: &ExportData) -> anyhow::Result { - match self { - Self::Json => Ok(serde_json::to_string_pretty(data)?), - Self::Toml => { - let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?; - toml::to_string_pretty(&toml_val) - .map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e)) - } - Self::Yaml => serde_yaml::to_string(data) - .map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)), - } - } - - /// Deserialize ExportData from a string in this format. - pub fn deserialize(&self, content: &str) -> anyhow::Result { - match self { - Self::Json => Ok(serde_json::from_str(content)?), - Self::Toml => { - let toml_val: toml::Value = toml::from_str(content) - .map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?; - let json_val = toml_to_json_value(&toml_val); - Ok(serde_json::from_value(json_val)?) - } - Self::Yaml => serde_yaml::from_str(content) - .map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)), - } - } -} - -/// Top-level structure for export/import files. -#[derive(Debug, Serialize, Deserialize)] -pub struct ExportData { - pub version: u32, - pub exported_at: String, - pub entries: Vec, -} - -/// A single entry with decrypted secrets for export/import. -#[derive(Debug, Serialize, Deserialize)] -pub struct ExportEntry { - pub name: String, - #[serde(default)] - pub folder: String, - #[serde(default, rename = "type")] - pub entry_type: String, - #[serde(default)] - pub notes: String, - #[serde(default)] - pub tags: Vec, - #[serde(default)] - pub metadata: Value, - /// Decrypted secret fields. None means no secrets in this export (--no-secrets). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub secrets: Option>, - /// Per-secret types (`text`, `password`, `key`, …). Omitted in legacy exports; importers default to `"text"`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub secret_types: Option>, -} - -// ── Multi-user models ────────────────────────────────────────────────────────── - -/// A registered user (created on first OAuth login). -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct User { - pub id: Uuid, - pub email: Option, - pub name: String, - pub avatar_url: Option, - /// PBKDF2 salt (32 B). NULL until user sets up passphrase. - pub key_salt: Option>, - /// AES-256-GCM encryption of the known constant "secrets-mcp-key-check". - /// Used to verify the passphrase without storing the key itself. - pub key_check: Option>, - /// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}. - pub key_params: Option, - /// Plaintext API key for MCP Bearer authentication. Auto-created on first login. - pub api_key: Option, - /// Incremented each time the passphrase is changed; used to invalidate sessions on other devices. - pub key_version: i64, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// An OAuth account linked to a user. -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct OauthAccount { - pub id: Uuid, - pub user_id: Uuid, - pub provider: String, - pub provider_id: String, - pub email: Option, - pub name: Option, - pub avatar_url: Option, - pub created_at: DateTime, -} - -/// A single audit log row, optionally scoped to a business user. -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct AuditLogEntry { - pub id: i64, - pub user_id: Option, - pub action: String, - pub folder: String, - #[serde(rename = "type")] - #[sqlx(rename = "type")] - pub entry_type: String, - pub name: String, - pub detail: Value, - pub created_at: DateTime, -} - -// ── TOML ↔ JSON value conversion ────────────────────────────────────────────── - -/// Convert a serde_json Value to a toml Value. -/// `null` values are filtered out (TOML does not support null). -/// Mixed-type arrays are serialised as JSON strings. -pub fn json_to_toml_value(v: &Value) -> anyhow::Result { - match v { - Value::Null => anyhow::bail!("TOML does not support null values"), - Value::Bool(b) => Ok(toml::Value::Boolean(*b)), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(toml::Value::Integer(i)) - } else if let Some(f) = n.as_f64() { - Ok(toml::Value::Float(f)) - } else { - anyhow::bail!("unsupported number: {}", n) - } - } - Value::String(s) => Ok(toml::Value::String(s.clone())), - Value::Array(arr) => { - let items: anyhow::Result> = - arr.iter().map(json_to_toml_value).collect(); - match items { - Ok(vals) => Ok(toml::Value::Array(vals)), - Err(e) => { - tracing::debug!(error = %e, "mixed-type array; falling back to JSON string"); - Ok(toml::Value::String(serde_json::to_string(v)?)) - } - } - } - Value::Object(map) => { - let mut toml_map = toml::map::Map::new(); - for (k, val) in map { - if val.is_null() { - // Skip null entries - continue; - } - match json_to_toml_value(val) { - Ok(tv) => { - toml_map.insert(k.clone(), tv); - } - Err(e) => { - tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string"); - toml_map - .insert(k.clone(), toml::Value::String(serde_json::to_string(val)?)); - } - } - } - Ok(toml::Value::Table(toml_map)) - } - } -} - -/// Convert a toml Value back to a serde_json Value. -pub fn toml_to_json_value(v: &toml::Value) -> Value { - match v { - toml::Value::Boolean(b) => Value::Bool(*b), - toml::Value::Integer(i) => Value::Number((*i).into()), - toml::Value::Float(f) => serde_json::Number::from_f64(*f) - .map(Value::Number) - .unwrap_or(Value::Null), - toml::Value::String(s) => Value::String(s.clone()), - toml::Value::Datetime(dt) => Value::String(dt.to_string()), - toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()), - toml::Value::Table(map) => { - let obj: serde_json::Map = map - .iter() - .map(|(k, v)| (k.clone(), toml_to_json_value(v))) - .collect(); - Value::Object(obj) - } - } -} - -#[cfg(test)] -mod export_entry_tests { - use super::*; - use std::collections::BTreeMap; - - #[test] - fn export_entry_roundtrip_includes_secret_types() { - let mut secrets = BTreeMap::new(); - secrets.insert("k".to_string(), serde_json::json!("v")); - let mut types = BTreeMap::new(); - types.insert("k".to_string(), "password".to_string()); - let e = ExportEntry { - name: "n".to_string(), - folder: "f".to_string(), - entry_type: "t".to_string(), - notes: "".to_string(), - tags: vec![], - metadata: serde_json::json!({}), - secrets: Some(secrets), - secret_types: Some(types), - }; - let json = serde_json::to_string(&e).unwrap(); - let back: ExportEntry = serde_json::from_str(&json).unwrap(); - assert_eq!( - back.secret_types - .as_ref() - .unwrap() - .get("k") - .map(String::as_str), - Some("password") - ); - } - - #[test] - fn export_entry_legacy_json_without_secret_types_deserializes() { - let json = r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{"x":"y"}}"#; - let e: ExportEntry = serde_json::from_str(json).unwrap(); - assert!(e.secret_types.is_none()); - } -} diff --git a/crates/secrets-core/src/service/add.rs b/crates/secrets-core/src/service/add.rs deleted file mode 100644 index 6faa0f9..0000000 --- a/crates/secrets-core/src/service/add.rs +++ /dev/null @@ -1,813 +0,0 @@ -use anyhow::Result; -use serde_json::{Map, Value}; -use sqlx::PgPool; -use std::collections::{BTreeSet, HashSet}; -use std::fs; -use uuid::Uuid; - -use crate::crypto; -use crate::db; -use crate::error::{AppError, DbErrorContext}; -use crate::models::EntryRow; - -// ── Key/value parsing helpers ───────────────────────────────────────────────── - -pub fn parse_kv(entry: &str) -> Result<(Vec, Value)> { - if let Some((key, json_str)) = entry.split_once(":=") { - let val: Value = serde_json::from_str(json_str).map_err(|e| { - anyhow::anyhow!( - "Invalid JSON value for key '{}': {} (use key=value for plain strings)", - key, - e - ) - })?; - return Ok((parse_key_path(key)?, val)); - } - - if let Some((key, raw_val)) = entry.split_once('=') { - let value = if let Some(path) = raw_val.strip_prefix('@') { - fs::read_to_string(path) - .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))? - } else { - raw_val.to_string() - }; - return Ok((parse_key_path(key)?, Value::String(value))); - } - - if let Some((key, path)) = entry.split_once('@') { - let value = fs::read_to_string(path) - .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?; - return Ok((parse_key_path(key)?, Value::String(value))); - } - - anyhow::bail!( - "Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=", - entry - ) -} - -pub fn build_json(entries: &[String]) -> Result { - let mut map = Map::new(); - for entry in entries { - let (path, value) = parse_kv(entry)?; - insert_path(&mut map, &path, value)?; - } - Ok(Value::Object(map)) -} - -pub fn key_path_to_string(path: &[String]) -> String { - path.join(":") -} - -pub fn collect_key_paths(entries: &[String]) -> Result> { - entries - .iter() - .map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path))) - .collect() -} - -pub fn collect_field_paths(entries: &[String]) -> Result> { - entries - .iter() - .map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path))) - .collect() -} - -pub fn parse_key_path(key: &str) -> Result> { - let path: Vec = key - .split(':') - .map(str::trim) - .map(ToOwned::to_owned) - .collect(); - - if path.is_empty() || path.iter().any(|part| part.is_empty()) { - anyhow::bail!( - "Invalid key path '{}'. Use non-empty segments like 'credentials:content'.", - key - ); - } - Ok(path) -} - -pub fn insert_path(map: &mut Map, path: &[String], value: Value) -> Result<()> { - if path.is_empty() { - anyhow::bail!("Key path cannot be empty"); - } - if path.len() == 1 { - map.insert(path[0].clone(), value); - return Ok(()); - } - let head = path[0].clone(); - let tail = &path[1..]; - match map.entry(head.clone()) { - serde_json::map::Entry::Vacant(entry) => { - let mut child = Map::new(); - insert_path(&mut child, tail, value)?; - entry.insert(Value::Object(child)); - } - serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() { - Value::Object(child) => insert_path(child, tail, value)?, - _ => { - anyhow::bail!( - "Cannot set nested key '{}' because '{}' is already a non-object value", - key_path_to_string(path), - head - ); - } - }, - } - Ok(()) -} - -pub fn remove_path(map: &mut Map, path: &[String]) -> Result { - if path.is_empty() { - anyhow::bail!("Key path cannot be empty"); - } - if path.len() == 1 { - return Ok(map.remove(&path[0]).is_some()); - } - let Some(value) = map.get_mut(&path[0]) else { - return Ok(false); - }; - let Value::Object(child) = value else { - return Ok(false); - }; - let removed = remove_path(child, &path[1..])?; - if child.is_empty() { - map.remove(&path[0]); - } - Ok(removed) -} - -pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> { - match value { - Value::Object(map) => { - let mut out = Vec::new(); - for (k, v) in map { - let full_key = if prefix.is_empty() { - k.clone() - } else { - format!("{}.{}", prefix, k) - }; - out.extend(flatten_json_fields(&full_key, v)); - } - out - } - other => vec![(prefix.to_string(), other.clone())], - } -} - -// ── AddResult ───────────────────────────────────────────────────────────────── - -#[derive(Debug, serde::Serialize)] -pub struct AddResult { - pub entry_id: Uuid, - pub name: String, - pub folder: String, - #[serde(rename = "type")] - pub entry_type: String, - pub tags: Vec, - pub meta_keys: Vec, - pub secret_keys: Vec, -} - -pub struct AddParams<'a> { - pub name: &'a str, - pub folder: &'a str, - pub entry_type: &'a str, - pub notes: &'a str, - pub tags: &'a [String], - pub meta_entries: &'a [String], - pub secret_entries: &'a [String], - pub secret_types: &'a std::collections::HashMap, - pub link_secret_names: &'a [String], - /// Optional user_id for multi-user isolation (None = single-user CLI mode) - pub user_id: Option, -} - -pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result { - if params.folder.chars().count() > 128 { - anyhow::bail!("folder must be at most 128 characters"); - } - if params.name.chars().count() > 256 { - anyhow::bail!("name must be at most 256 characters"); - } - if params.entry_type.trim().chars().count() > 64 { - anyhow::bail!("type must be at most 64 characters"); - } - let Value::Object(metadata_map) = build_json(params.meta_entries)? else { - unreachable!("build_json always returns a JSON object"); - }; - let entry_type = params.entry_type.trim(); - let metadata = Value::Object(metadata_map); - let secret_json = build_json(params.secret_entries)?; - let meta_keys = collect_key_paths(params.meta_entries)?; - let secret_keys = collect_key_paths(params.secret_entries)?; - let flat_fields = flatten_json_fields("", &secret_json); - let new_secret_names: BTreeSet = - flat_fields.iter().map(|(name, _)| name.clone()).collect(); - let link_secret_names = - validate_link_secret_names(params.link_secret_names, &new_secret_names)?; - - let mut tx = pool.begin().await?; - - // Fetch existing entry by (user_id, folder, name) — the natural unique key - let existing: Option = if let Some(uid) = params.user_id { - sqlx::query_as( - "SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \ - WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL", - ) - .bind(uid) - .bind(params.folder) - .bind(params.name) - .fetch_optional(&mut *tx) - .await? - } else { - sqlx::query_as( - "SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \ - WHERE user_id IS NULL AND folder = $1 AND name = $2 AND deleted_at IS NULL", - ) - .bind(params.folder) - .bind(params.name) - .fetch_optional(&mut *tx) - .await? - }; - - if let Some(ref ex) = existing { - let history_metadata = - match db::metadata_with_secret_snapshot(&mut tx, ex.id, &ex.metadata).await { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); - ex.metadata.clone() - } - }; - if let Err(e) = db::snapshot_entry_history( - &mut tx, - db::EntrySnapshotParams { - entry_id: ex.id, - user_id: params.user_id, - folder: params.folder, - entry_type, - name: params.name, - version: ex.version, - action: "add", - tags: &ex.tags, - metadata: &history_metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry history before upsert"); - } - } - - // Upsert the entry row. On conflict (existing entry with same user_id+folder+name), - // the entry columns are replaced wholesale. The old secret associations are torn down - // below within the same transaction, so the whole operation is atomic: if any step - // after this point fails, the transaction rolls back and the entry reverts to its - // pre-upsert state (including the version bump that happened in the DO UPDATE clause). - let entry_id: Uuid = if let Some(uid) = params.user_id { - sqlx::query_scalar( - r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW()) - ON CONFLICT (user_id, folder, name) WHERE user_id IS NOT NULL - DO UPDATE SET - folder = EXCLUDED.folder, - type = EXCLUDED.type, - notes = EXCLUDED.notes, - tags = EXCLUDED.tags, - metadata = EXCLUDED.metadata, - version = entries.version + 1, - updated_at = NOW() - RETURNING id"#, - ) - .bind(uid) - .bind(params.folder) - .bind(entry_type) - .bind(params.name) - .bind(params.notes) - .bind(params.tags) - .bind(&metadata) - .fetch_one(&mut *tx) - .await? - } else { - sqlx::query_scalar( - r#"INSERT INTO entries (folder, type, name, notes, tags, metadata, version, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, 1, NOW()) - ON CONFLICT (folder, name) WHERE user_id IS NULL - DO UPDATE SET - folder = EXCLUDED.folder, - type = EXCLUDED.type, - notes = EXCLUDED.notes, - tags = EXCLUDED.tags, - metadata = EXCLUDED.metadata, - version = entries.version + 1, - updated_at = NOW() - RETURNING id"#, - ) - .bind(params.folder) - .bind(entry_type) - .bind(params.name) - .bind(params.notes) - .bind(params.tags) - .bind(&metadata) - .fetch_one(&mut *tx) - .await? - }; - - let current_entry_version: i64 = - sqlx::query_scalar("SELECT version FROM entries WHERE id = $1") - .bind(entry_id) - .fetch_one(&mut *tx) - .await?; - - if existing.is_some() { - #[derive(sqlx::FromRow)] - struct ExistingField { - id: Uuid, - name: String, - encrypted: Vec, - } - let existing_fields: Vec = sqlx::query_as( - "SELECT s.id, s.name, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1", - ) - .bind(entry_id) - .fetch_all(&mut *tx) - .await?; - - for f in &existing_fields { - if let Err(e) = db::snapshot_secret_history( - &mut tx, - db::SecretSnapshotParams { - secret_id: f.id, - name: &f.name, - encrypted: &f.encrypted, - action: "add", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret field history"); - } - } - - let orphan_candidates: Vec = existing_fields.iter().map(|f| f.id).collect(); - - sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1") - .bind(entry_id) - .execute(&mut *tx) - .await?; - - if !orphan_candidates.is_empty() { - sqlx::query( - "DELETE FROM secrets s \ - WHERE s.id = ANY($1) \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", - ) - .bind(&orphan_candidates) - .execute(&mut *tx) - .await?; - } - } - - for (field_name, field_value) in &flat_fields { - let encrypted = crypto::encrypt_json(master_key, field_value)?; - let secret_type = params - .secret_types - .get(field_name) - .map(|s| s.as_str()) - .unwrap_or("text"); - let secret_id: Uuid = sqlx::query_scalar( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", - ) - .bind(params.user_id) - .bind(field_name) - .bind(secret_type) - .bind(&encrypted) - .fetch_one(&mut *tx) - .await - .map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?; - sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)") - .bind(entry_id) - .bind(secret_id) - .execute(&mut *tx) - .await?; - } - - for link_name in &link_secret_names { - let secret_ids: Vec = if let Some(uid) = params.user_id { - sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2") - .bind(uid) - .bind(link_name) - .fetch_all(&mut *tx) - .await? - } else { - sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1") - .bind(link_name) - .fetch_all(&mut *tx) - .await? - }; - - match secret_ids.len() { - 0 => anyhow::bail!("Not found: secret named '{}'", link_name), - 1 => { - sqlx::query( - "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(entry_id) - .bind(secret_ids[0]) - .execute(&mut *tx) - .await?; - } - n => anyhow::bail!( - "Ambiguous: {} secrets named '{}' found. Please deduplicate names first.", - n, - link_name - ), - } - } - - if existing.is_none() { - let history_metadata = - match db::metadata_with_secret_snapshot(&mut tx, entry_id, &metadata).await { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); - metadata.clone() - } - }; - if let Err(e) = db::snapshot_entry_history( - &mut tx, - db::EntrySnapshotParams { - entry_id, - user_id: params.user_id, - folder: params.folder, - entry_type, - name: params.name, - version: current_entry_version, - action: "create", - tags: params.tags, - metadata: &history_metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry history on create"); - } - } - - crate::audit::log_tx( - &mut tx, - params.user_id, - "add", - params.folder, - entry_type, - params.name, - serde_json::json!({ - "tags": params.tags, - "meta_keys": meta_keys, - "secret_keys": secret_keys, - }), - ) - .await; - - tx.commit().await?; - - Ok(AddResult { - entry_id, - name: params.name.to_string(), - folder: params.folder.to_string(), - entry_type: entry_type.to_string(), - tags: params.tags.to_vec(), - meta_keys, - secret_keys, - }) -} - -fn validate_link_secret_names( - link_secret_names: &[String], - new_secret_names: &BTreeSet, -) -> Result> { - let mut deduped = Vec::new(); - let mut seen = HashSet::new(); - - for raw in link_secret_names { - let trimmed = raw.trim(); - if trimmed.is_empty() { - anyhow::bail!("link_secret_names contains an empty name"); - } - if new_secret_names.contains(trimmed) { - anyhow::bail!( - "Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names", - trimmed - ); - } - if seen.insert(trimmed.to_string()) { - deduped.push(trimmed.to_string()); - } - } - - Ok(deduped) -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::PgPool; - use std::collections::BTreeSet; - - #[test] - fn parse_nested_file_shorthand() { - use std::io::Write; - let mut f = tempfile::NamedTempFile::new().unwrap(); - writeln!(f, "line1\nline2").unwrap(); - let path = f.path().to_str().unwrap().to_string(); - let entry = format!("credentials:content@{}", path); - let (path_parts, value) = parse_kv(&entry).unwrap(); - assert_eq!(key_path_to_string(&path_parts), "credentials:content"); - assert!(matches!(value, Value::String(_))); - } - - #[test] - fn flatten_json_fields_nested() { - let v = serde_json::json!({ - "username": "root", - "credentials": { - "type": "ssh", - "content": "pem" - } - }); - let mut fields = flatten_json_fields("", &v); - fields.sort_by(|a, b| a.0.cmp(&b.0)); - assert_eq!(fields[0].0, "credentials.content"); - assert_eq!(fields[1].0, "credentials.type"); - assert_eq!(fields[2].0, "username"); - } - - #[test] - fn validate_link_secret_names_conflict_with_new_secret() { - let mut new_names = BTreeSet::new(); - new_names.insert("password".to_string()); - let err = validate_link_secret_names(&[String::from("password")], &new_names) - .expect_err("must fail on overlap"); - assert!( - err.to_string() - .contains("provided both in secrets/secrets_obj and link_secret_names") - ); - } - - #[test] - fn validate_link_secret_names_dedup_and_trim() { - let names = vec![ - " shared_key ".to_string(), - "shared_key".to_string(), - "runner_token".to_string(), - ]; - let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap(); - assert_eq!(deduped, vec!["shared_key", "runner_token"]); - } - - async fn maybe_test_pool() -> Option { - let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else { - eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set"); - return None; - }; - let Ok(pool) = PgPool::connect(&url).await else { - eprintln!("skip add linkage tests: cannot connect to database"); - return None; - }; - if let Err(e) = crate::db::migrate(&pool).await { - eprintln!("skip add linkage tests: migrate failed: {e}"); - return None; - } - Some(pool) - } - - async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> { - sqlx::query( - "DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)", - ) - .bind(format!("%{marker}%")) - .execute(pool) - .await?; - sqlx::query( - "DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)", - ) - .bind(format!("%{marker}%")) - .execute(pool) - .await?; - Ok(()) - } - - #[tokio::test] - async fn add_links_existing_secret_by_unique_name() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let suffix = Uuid::from_u128(rand::random()).to_string(); - let marker = format!("link_unique_{}", &suffix[..8]); - let secret_name = format!("{}_secret", marker); - let entry_name = format!("{}_entry", marker); - - cleanup_test_rows(&pool, &marker).await?; - - let secret_id: Uuid = sqlx::query_scalar( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id", - ) - .bind(&secret_name) - .bind(vec![1_u8, 2, 3]) - .fetch_one(&pool) - .await?; - - run( - &pool, - AddParams { - name: &entry_name, - folder: &marker, - entry_type: "service", - notes: "", - tags: &[], - meta_entries: &[], - secret_entries: &[], - secret_types: &Default::default(), - link_secret_names: std::slice::from_ref(&secret_name), - user_id: None, - }, - &[0_u8; 32], - ) - .await?; - - let linked: bool = sqlx::query_scalar( - "SELECT EXISTS( \ - SELECT 1 FROM entry_secrets es \ - JOIN entries e ON e.id = es.entry_id \ - WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \ - )", - ) - .bind(&entry_name) - .bind(secret_id) - .fetch_one(&pool) - .await?; - assert!(linked); - - cleanup_test_rows(&pool, &marker).await?; - Ok(()) - } - - #[tokio::test] - async fn add_link_secret_name_not_found_fails() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let suffix = Uuid::from_u128(rand::random()).to_string(); - let marker = format!("link_missing_{}", &suffix[..8]); - let secret_name = format!("{}_secret", marker); - let entry_name = format!("{}_entry", marker); - - cleanup_test_rows(&pool, &marker).await?; - - let err = run( - &pool, - AddParams { - name: &entry_name, - folder: &marker, - entry_type: "service", - notes: "", - tags: &[], - meta_entries: &[], - secret_entries: &[], - secret_types: &Default::default(), - link_secret_names: std::slice::from_ref(&secret_name), - user_id: None, - }, - &[0_u8; 32], - ) - .await - .expect_err("must fail when linked secret is not found"); - assert!(err.to_string().contains("Not found: secret named")); - - cleanup_test_rows(&pool, &marker).await?; - Ok(()) - } - - #[tokio::test] - async fn add_link_secret_name_ambiguous_fails() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let suffix = Uuid::from_u128(rand::random()).to_string(); - let marker = format!("link_amb_{}", &suffix[..8]); - let secret_name = format!("{}_dup_secret", marker); - let entry_name = format!("{}_entry", marker); - - cleanup_test_rows(&pool, &marker).await?; - - sqlx::query( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)", - ) - .bind(&secret_name) - .bind(vec![1_u8]) - .execute(&pool) - .await?; - sqlx::query( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)", - ) - .bind(&secret_name) - .bind(vec![2_u8]) - .execute(&pool) - .await?; - - let err = run( - &pool, - AddParams { - name: &entry_name, - folder: &marker, - entry_type: "service", - notes: "", - tags: &[], - meta_entries: &[], - secret_entries: &[], - secret_types: &Default::default(), - link_secret_names: std::slice::from_ref(&secret_name), - user_id: None, - }, - &[0_u8; 32], - ) - .await - .expect_err("must fail on ambiguous linked secret name"); - assert!(err.to_string().contains("Ambiguous:")); - - cleanup_test_rows(&pool, &marker).await?; - Ok(()) - } - - #[tokio::test] - async fn add_duplicate_secret_name_returns_conflict_error() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let suffix = Uuid::from_u128(rand::random()).to_string(); - let marker = format!("dup_secret_{}", &suffix[..8]); - let entry_name = format!("{}_entry", marker); - let secret_name = "shared_token"; - - cleanup_test_rows(&pool, &marker).await?; - - // First add succeeds - run( - &pool, - AddParams { - name: &entry_name, - folder: &marker, - entry_type: "service", - notes: "", - tags: &[], - meta_entries: &[], - secret_entries: &[format!("{}=value1", secret_name)], - secret_types: &Default::default(), - link_secret_names: &[], - user_id: None, - }, - &[0_u8; 32], - ) - .await?; - - // Second add with same secret name under same user_id should fail with ConflictSecretName - let entry_name2 = format!("{}_entry2", marker); - let err = run( - &pool, - AddParams { - name: &entry_name2, - folder: &marker, - entry_type: "service", - notes: "", - tags: &[], - meta_entries: &[], - secret_entries: &[format!("{}=value2", secret_name)], - secret_types: &Default::default(), - link_secret_names: &[], - user_id: None, - }, - &[0_u8; 32], - ) - .await - .expect_err("must fail on duplicate secret name"); - - let app_err = err - .downcast_ref::() - .expect("error should be AppError"); - assert!( - matches!(app_err, crate::error::AppError::ConflictSecretName { .. }), - "expected ConflictSecretName, got: {}", - app_err - ); - - cleanup_test_rows(&pool, &marker).await?; - Ok(()) - } -} diff --git a/crates/secrets-core/src/service/api_key.rs b/crates/secrets-core/src/service/api_key.rs deleted file mode 100644 index abed3cc..0000000 --- a/crates/secrets-core/src/service/api_key.rs +++ /dev/null @@ -1,95 +0,0 @@ -use anyhow::Result; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::error::AppError; - -const KEY_PREFIX: &str = "sk_"; - -/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total. -pub fn generate_api_key() -> String { - use rand::RngExt; - let mut bytes = [0u8; 32]; - rand::rng().fill(&mut bytes); - format!("{}{}", KEY_PREFIX, ::hex::encode(bytes)) -} - -/// Return the user's existing API key, or generate and store a new one if NULL. -/// Uses a transaction with atomic update to prevent TOCTOU race conditions. -pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result { - let mut tx = pool.begin().await?; - - // Lock the row and check existing key - let existing: (Option,) = - sqlx::query_as("SELECT api_key FROM users WHERE id = $1 FOR UPDATE") - .bind(user_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFoundUser)?; - - if let Some(key) = existing.0 { - tx.commit().await?; - return Ok(key); - } - - // Generate and store new key atomically - let new_key = generate_api_key(); - sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2") - .bind(&new_key) - .bind(user_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(new_key) -} - -/// Generate a fresh API key for the user, replacing the old one. -pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result { - let new_key = generate_api_key(); - let res = sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2") - .bind(&new_key) - .bind(user_id) - .execute(pool) - .await?; - if res.rows_affected() == 0 { - return Err(AppError::NotFoundUser.into()); - } - Ok(new_key) -} - -/// Validate a Bearer token. Returns the `user_id` if the key matches. -pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result> { - let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE api_key = $1") - .bind(raw_key) - .fetch_optional(pool) - .await?; - Ok(row.map(|(id,)| id)) -} - -#[cfg(test)] -mod tests { - use sqlx::PgPool; - - use super::regenerate_api_key; - use crate::error::AppError; - - #[tokio::test] - async fn regenerate_api_key_unknown_user_returns_not_found() { - let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else { - return; - }; - let Ok(pool) = PgPool::connect(&url).await else { - return; - }; - let id = uuid::Uuid::new_v4(); - let err = regenerate_api_key(&pool, id) - .await - .err() - .expect("expected error"); - assert!(matches!( - err.downcast_ref::(), - Some(AppError::NotFoundUser) - )); - } -} diff --git a/crates/secrets-core/src/service/audit_log.rs b/crates/secrets-core/src/service/audit_log.rs deleted file mode 100644 index 5c72b42..0000000 --- a/crates/secrets-core/src/service/audit_log.rs +++ /dev/null @@ -1,39 +0,0 @@ -use anyhow::Result; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::models::AuditLogEntry; - -pub async fn list_for_user( - pool: &PgPool, - user_id: Uuid, - limit: i64, - offset: i64, -) -> Result> { - let limit = limit.clamp(1, 200); - let offset = offset.max(0); - - let rows = sqlx::query_as( - "SELECT id, user_id, action, folder, type, name, detail, created_at \ - FROM audit_log \ - WHERE user_id = $1 \ - ORDER BY created_at DESC, id DESC \ - LIMIT $2 OFFSET $3", - ) - .bind(user_id) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(rows) -} - -pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*)::bigint FROM audit_log WHERE user_id = $1") - .bind(user_id) - .fetch_one(pool) - .await?; - Ok(count) -} diff --git a/crates/secrets-core/src/service/delete.rs b/crates/secrets-core/src/service/delete.rs deleted file mode 100644 index 0e8cd4a..0000000 --- a/crates/secrets-core/src/service/delete.rs +++ /dev/null @@ -1,823 +0,0 @@ -use anyhow::Result; -use serde_json::json; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::db; -use crate::error::AppError; -use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow}; -use crate::service::util::user_scope_condition; - -#[derive(Debug, serde::Serialize)] -pub struct DeletedEntry { - pub name: String, - pub folder: String, - #[serde(rename = "type")] - pub entry_type: String, -} - -#[derive(Debug, serde::Serialize)] -pub struct DeleteResult { - pub deleted: Vec, - pub dry_run: bool, -} - -#[derive(Debug, serde::Serialize, sqlx::FromRow)] -pub struct TrashEntry { - pub id: Uuid, - pub name: String, - pub folder: String, - #[serde(rename = "type")] - #[sqlx(rename = "type")] - pub entry_type: String, - pub deleted_at: chrono::DateTime, -} - -pub struct DeleteParams<'a> { - /// If set, delete a single entry by name. - pub name: Option<&'a str>, - /// Folder filter for bulk delete. - pub folder: Option<&'a str>, - /// Type filter for bulk delete. - pub entry_type: Option<&'a str>, - pub dry_run: bool, - pub user_id: Option, -} - -/// Maximum number of entries that can be deleted in a single bulk operation. -/// Prevents accidental mass deletion when filters are too broad. -pub const MAX_BULK_DELETE: usize = 1000; - -pub async fn list_deleted_entries( - pool: &PgPool, - user_id: Uuid, - limit: u32, - offset: u32, -) -> Result> { - sqlx::query_as( - "SELECT id, name, folder, type, deleted_at FROM entries \ - WHERE user_id = $1 AND deleted_at IS NOT NULL \ - ORDER BY deleted_at DESC, name ASC LIMIT $2 OFFSET $3", - ) - .bind(user_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(pool) - .await - .map_err(Into::into) -} - -pub async fn count_deleted_entries(pool: &PgPool, user_id: Uuid) -> Result { - sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*)::bigint FROM entries WHERE user_id = $1 AND deleted_at IS NOT NULL", - ) - .bind(user_id) - .fetch_one(pool) - .await - .map_err(Into::into) -} - -pub async fn restore_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> { - let mut tx = pool.begin().await?; - let row: Option = sqlx::query_as( - "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ - WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE", - ) - .bind(entry_id) - .bind(user_id) - .fetch_optional(&mut *tx) - .await?; - - let row = match row { - Some(r) => r, - None => { - tx.rollback().await?; - return Err(AppError::NotFoundEntry.into()); - } - }; - - let conflict_exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM entries \ - WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL AND id <> $4)", - ) - .bind(user_id) - .bind(&row.folder) - .bind(&row.name) - .bind(row.id) - .fetch_one(&mut *tx) - .await?; - if conflict_exists { - tx.rollback().await?; - return Err(AppError::ConflictEntryName { - folder: row.folder, - name: row.name, - } - .into()); - } - - sqlx::query("UPDATE entries SET deleted_at = NULL, updated_at = NOW() WHERE id = $1") - .bind(row.id) - .execute(&mut *tx) - .await?; - - crate::audit::log_tx( - &mut tx, - Some(user_id), - "restore", - &row.folder, - &row.entry_type, - &row.name, - json!({ "entry_id": row.id }), - ) - .await; - tx.commit().await?; - Ok(()) -} - -pub async fn purge_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> { - let mut tx = pool.begin().await?; - let row: Option = sqlx::query_as( - "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ - WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE", - ) - .bind(entry_id) - .bind(user_id) - .fetch_optional(&mut *tx) - .await?; - - let row = match row { - Some(r) => r, - None => { - tx.rollback().await?; - return Err(AppError::NotFoundEntry.into()); - } - }; - - purge_entry_record(&mut tx, row.id).await?; - crate::audit::log_tx( - &mut tx, - Some(user_id), - "purge", - &row.folder, - &row.entry_type, - &row.name, - json!({ "entry_id": row.id }), - ) - .await; - tx.commit().await?; - Ok(()) -} - -pub async fn purge_expired_deleted_entries(pool: &PgPool) -> Result { - #[derive(sqlx::FromRow)] - struct ExpiredRow { - id: Uuid, - } - - let mut tx = pool.begin().await?; - let rows: Vec = sqlx::query_as( - "SELECT id FROM entries \ - WHERE deleted_at IS NOT NULL \ - AND deleted_at < NOW() - INTERVAL '3 months' \ - FOR UPDATE", - ) - .fetch_all(&mut *tx) - .await?; - - for row in &rows { - purge_entry_record(&mut tx, row.id).await?; - } - - tx.commit().await?; - Ok(rows.len() as u64) -} - -/// Delete a single entry by id (multi-tenant: `user_id` must match). -pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result { - let mut tx = pool.begin().await?; - let row: Option = sqlx::query_as( - "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ - WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE", - ) - .bind(entry_id) - .bind(user_id) - .fetch_optional(&mut *tx) - .await?; - - let row = match row { - Some(r) => r, - None => { - tx.rollback().await?; - anyhow::bail!("Entry not found"); - } - }; - - let folder = row.folder.clone(); - let entry_type = row.entry_type.clone(); - let name = row.name.clone(); - let entry_row: EntryRow = (&row).into(); - - snapshot_and_soft_delete( - &mut tx, - &folder, - &entry_type, - &name, - &entry_row, - Some(user_id), - ) - .await?; - crate::audit::log_tx( - &mut tx, - Some(user_id), - "delete", - &folder, - &entry_type, - &name, - json!({ "source": "web", "entry_id": entry_id }), - ) - .await; - tx.commit().await?; - - Ok(DeleteResult { - deleted: vec![DeletedEntry { - name, - folder, - entry_type, - }], - dry_run: false, - }) -} - -pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result { - match params.name { - Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await, - None => { - if params.folder.is_none() && params.entry_type.is_none() { - anyhow::bail!( - "Bulk delete requires at least one of: name, folder, or type filter." - ); - } - delete_bulk( - pool, - params.folder, - params.entry_type, - params.dry_run, - params.user_id, - ) - .await - } - } -} - -async fn delete_one( - pool: &PgPool, - name: &str, - folder: Option<&str>, - dry_run: bool, - user_id: Option, -) -> Result { - if dry_run { - // Dry-run uses the same disambiguation logic as actual delete: - // - 0 matches → nothing to delete - // - 1 match → show what would be deleted (with correct folder/type) - // - 2+ matches → disambiguation error (same as non-dry-run) - #[derive(sqlx::FromRow)] - struct DryRunRow { - folder: String, - #[sqlx(rename = "type")] - entry_type: String, - } - - let mut idx = 1i32; - let user_cond = user_scope_condition(user_id, &mut idx); - let mut conditions = vec![user_cond]; - if folder.is_some() { - conditions.push(format!("folder = ${}", idx)); - idx += 1; - } - conditions.push(format!("name = ${}", idx)); - let sql = format!( - "SELECT folder, type FROM entries WHERE {} AND deleted_at IS NULL", - conditions.join(" AND ") - ); - let mut q = sqlx::query_as::<_, DryRunRow>(&sql); - if let Some(uid) = user_id { - q = q.bind(uid); - } - if let Some(f) = folder { - q = q.bind(f); - } - q = q.bind(name); - let rows = q.fetch_all(pool).await?; - - return match rows.len() { - 0 => Ok(DeleteResult { - deleted: vec![], - dry_run: true, - }), - 1 => { - let row = rows - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?; - Ok(DeleteResult { - deleted: vec![DeletedEntry { - name: name.to_string(), - folder: row.folder, - entry_type: row.entry_type, - }], - dry_run: true, - }) - } - _ => { - let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect(); - anyhow::bail!( - "Ambiguous: {} entries named '{}' found in folders: [{}]. \ - Specify 'folder' to disambiguate.", - rows.len(), - name, - folders.join(", ") - ) - } - }; - } - - let mut tx = pool.begin().await?; - - // Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity. - let mut idx = 1i32; - let user_cond = user_scope_condition(user_id, &mut idx); - let mut conditions = vec![user_cond]; - if folder.is_some() { - conditions.push(format!("folder = ${}", idx)); - idx += 1; - } - conditions.push(format!("name = ${}", idx)); - let sql = format!( - "SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \ - WHERE {} AND deleted_at IS NULL FOR UPDATE", - conditions.join(" AND ") - ); - let mut q = sqlx::query_as::<_, EntryRow>(&sql); - if let Some(uid) = user_id { - q = q.bind(uid); - } - if let Some(f) = folder { - q = q.bind(f); - } - q = q.bind(name); - let rows = q.fetch_all(&mut *tx).await?; - - let row = match rows.len() { - 0 => { - tx.rollback().await?; - return Ok(DeleteResult { - deleted: vec![], - dry_run: false, - }); - } - 1 => rows - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?, - _ => { - tx.rollback().await?; - let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect(); - anyhow::bail!( - "Ambiguous: {} entries named '{}' found in folders: [{}]. \ - Specify 'folder' to disambiguate.", - rows.len(), - name, - folders.join(", ") - ) - } - }; - - let folder = row.folder.clone(); - let entry_type = row.entry_type.clone(); - snapshot_and_soft_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?; - crate::audit::log_tx( - &mut tx, - user_id, - "delete", - &folder, - &entry_type, - name, - json!({}), - ) - .await; - tx.commit().await?; - - Ok(DeleteResult { - deleted: vec![DeletedEntry { - name: name.to_string(), - folder, - entry_type, - }], - dry_run: false, - }) -} - -async fn delete_bulk( - pool: &PgPool, - folder: Option<&str>, - entry_type: Option<&str>, - dry_run: bool, - user_id: Option, -) -> Result { - #[derive(Debug, sqlx::FromRow)] - struct FullEntryRow { - id: Uuid, - version: i64, - folder: String, - #[sqlx(rename = "type")] - entry_type: String, - name: String, - metadata: serde_json::Value, - tags: Vec, - notes: String, - } - - let mut conditions: Vec = Vec::new(); - let mut idx: i32 = 1; - - if user_id.is_some() { - conditions.push(format!("user_id = ${}", idx)); - idx += 1; - } else { - conditions.push("user_id IS NULL".to_string()); - } - if folder.is_some() { - conditions.push(format!("folder = ${}", idx)); - idx += 1; - } - if entry_type.is_some() { - conditions.push(format!("type = ${}", idx)); - idx += 1; - } - - let where_clause = format!("WHERE {}", conditions.join(" AND ")); - let _ = idx; // used only for placeholder numbering in conditions - - if dry_run { - let sql = format!( - "SELECT id, version, folder, type, name, metadata, tags, notes \ - FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name" - ); - let mut q = sqlx::query_as::<_, FullEntryRow>(&sql); - if let Some(uid) = user_id { - q = q.bind(uid); - } - if let Some(f) = folder { - q = q.bind(f); - } - if let Some(t) = entry_type { - q = q.bind(t); - } - let rows = q.fetch_all(pool).await?; - - let deleted = rows - .iter() - .map(|r| DeletedEntry { - name: r.name.clone(), - folder: r.folder.clone(), - entry_type: r.entry_type.clone(), - }) - .collect(); - return Ok(DeleteResult { - deleted, - dry_run: true, - }); - } - - let mut tx = pool.begin().await?; - - let sql = format!( - "SELECT id, version, folder, type, name, metadata, tags, notes \ - FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name FOR UPDATE" - ); - let mut q = sqlx::query_as::<_, FullEntryRow>(&sql); - if let Some(uid) = user_id { - q = q.bind(uid); - } - if let Some(f) = folder { - q = q.bind(f); - } - if let Some(t) = entry_type { - q = q.bind(t); - } - let rows = q.fetch_all(&mut *tx).await?; - - if rows.len() > MAX_BULK_DELETE { - tx.rollback().await?; - anyhow::bail!( - "Bulk delete would affect {} entries (limit: {}). \ - Narrow your filters or delete entries individually.", - rows.len(), - MAX_BULK_DELETE, - ); - } - - let mut deleted = Vec::with_capacity(rows.len()); - for row in &rows { - let entry_row: EntryRow = EntryRow { - id: row.id, - version: row.version, - folder: row.folder.clone(), - entry_type: row.entry_type.clone(), - tags: row.tags.clone(), - metadata: row.metadata.clone(), - notes: row.notes.clone(), - name: row.name.clone(), - }; - snapshot_and_soft_delete( - &mut tx, - &row.folder, - &row.entry_type, - &row.name, - &entry_row, - user_id, - ) - .await?; - crate::audit::log_tx( - &mut tx, - user_id, - "delete", - &row.folder, - &row.entry_type, - &row.name, - json!({"bulk": true}), - ) - .await; - deleted.push(DeletedEntry { - name: row.name.clone(), - folder: row.folder.clone(), - entry_type: row.entry_type.clone(), - }); - } - - tx.commit().await?; - - Ok(DeleteResult { - deleted, - dry_run: false, - }) -} - -async fn snapshot_and_soft_delete( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - folder: &str, - entry_type: &str, - name: &str, - row: &EntryRow, - user_id: Option, -) -> Result<()> { - let history_metadata = match db::metadata_with_secret_snapshot(tx, row.id, &row.metadata).await - { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); - row.metadata.clone() - } - }; - - if let Err(e) = db::snapshot_entry_history( - tx, - db::EntrySnapshotParams { - entry_id: row.id, - user_id, - folder, - entry_type, - name, - version: row.version, - action: "delete", - tags: &row.tags, - metadata: &history_metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry history before delete"); - } - - let fields: Vec = sqlx::query_as( - "SELECT s.id, s.name, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1", - ) - .bind(row.id) - .fetch_all(&mut **tx) - .await?; - - for f in &fields { - if let Err(e) = db::snapshot_secret_history( - tx, - db::SecretSnapshotParams { - secret_id: f.id, - name: &f.name, - encrypted: &f.encrypted, - action: "delete", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret history before delete"); - } - } - - sqlx::query("UPDATE entries SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1") - .bind(row.id) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn purge_entry_record( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - entry_id: Uuid, -) -> Result<()> { - let fields: Vec = sqlx::query_as( - "SELECT s.id, s.name, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1", - ) - .bind(entry_id) - .fetch_all(&mut **tx) - .await?; - - sqlx::query("DELETE FROM entries WHERE id = $1") - .bind(entry_id) - .execute(&mut **tx) - .await?; - - let secret_ids: Vec = fields.iter().map(|f| f.id).collect(); - if !secret_ids.is_empty() { - sqlx::query( - "DELETE FROM secrets s \ - WHERE s.id = ANY($1) \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", - ) - .bind(&secret_ids) - .execute(&mut **tx) - .await?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::PgPool; - - async fn maybe_test_pool() -> Option { - let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else { - eprintln!("skip delete tests: SECRETS_DATABASE_URL is not set"); - return None; - }; - let Ok(pool) = PgPool::connect(&url).await else { - eprintln!("skip delete tests: cannot connect to database"); - return None; - }; - if let Err(e) = crate::db::migrate(&pool).await { - eprintln!("skip delete tests: migrate failed: {e}"); - return None; - } - Some(pool) - } - - async fn cleanup_single_user_rows(pool: &PgPool, marker: &str) -> Result<()> { - sqlx::query( - "DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)", - ) - .bind(format!("%{marker}%")) - .execute(pool) - .await?; - sqlx::query( - "DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)", - ) - .bind(format!("%{marker}%")) - .execute(pool) - .await?; - Ok(()) - } - - #[tokio::test] - async fn delete_dry_run_reports_matching_entry_without_writes() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let suffix = Uuid::from_u128(rand::random()).to_string(); - let marker = format!("delete_dry_{}", &suffix[..8]); - let entry_name = format!("{}_entry", marker); - - cleanup_single_user_rows(&pool, &marker).await?; - - sqlx::query( - "INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \ - VALUES (NULL, $1, 'service', $2, '', '{}', '{}')", - ) - .bind(&marker) - .bind(&entry_name) - .execute(&pool) - .await?; - - let result = run( - &pool, - DeleteParams { - name: Some(&entry_name), - folder: Some(&marker), - entry_type: None, - dry_run: true, - user_id: None, - }, - ) - .await?; - - assert!(result.dry_run); - assert_eq!(result.deleted.len(), 1); - assert_eq!(result.deleted[0].name, entry_name); - - let still_exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2)", - ) - .bind(&marker) - .bind(&entry_name) - .fetch_one(&pool) - .await?; - assert!(still_exists); - - cleanup_single_user_rows(&pool, &marker).await?; - Ok(()) - } - - #[tokio::test] - async fn delete_by_id_removes_entry_and_orphan_secret() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let suffix = Uuid::from_u128(rand::random()).to_string(); - let marker = format!("delete_id_{}", &suffix[..8]); - let user_id = Uuid::from_u128(rand::random()); - let entry_name = format!("{}_entry", marker); - let secret_name = format!("{}_secret", marker); - - sqlx::query("DELETE FROM entries WHERE user_id = $1 AND folder = $2") - .bind(user_id) - .bind(&marker) - .execute(&pool) - .await?; - sqlx::query("DELETE FROM secrets WHERE user_id = $1 AND name = $2") - .bind(user_id) - .bind(&secret_name) - .execute(&pool) - .await?; - - let entry_id: Uuid = sqlx::query_scalar( - "INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \ - VALUES ($1, $2, 'service', $3, '', '{}', '{}') RETURNING id", - ) - .bind(user_id) - .bind(&marker) - .bind(&entry_name) - .fetch_one(&pool) - .await?; - let secret_id: Uuid = sqlx::query_scalar( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, 'text', $3) RETURNING id", - ) - .bind(user_id) - .bind(&secret_name) - .bind(vec![1_u8, 2, 3]) - .fetch_one(&pool) - .await?; - sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)") - .bind(entry_id) - .bind(secret_id) - .execute(&pool) - .await?; - - let result = delete_by_id(&pool, entry_id, user_id).await?; - assert!(!result.dry_run); - assert_eq!(result.deleted.len(), 1); - assert_eq!(result.deleted[0].name, entry_name); - - let entry_exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1)") - .bind(entry_id) - .fetch_one(&pool) - .await?; - let secret_exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM secrets WHERE id = $1)") - .bind(secret_id) - .fetch_one(&pool) - .await?; - assert!(!entry_exists); - assert!(!secret_exists); - - Ok(()) - } -} diff --git a/crates/secrets-core/src/service/env_map.rs b/crates/secrets-core/src/service/env_map.rs deleted file mode 100644 index 34a3c00..0000000 --- a/crates/secrets-core/src/service/env_map.rs +++ /dev/null @@ -1,122 +0,0 @@ -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use std::collections::HashMap; -use uuid::Uuid; - -use crate::crypto; -use crate::service::search::{fetch_entries, fetch_secrets_for_entries}; - -/// Build an env variable map from entry secrets (for dry-run preview or injection). -#[allow(clippy::too_many_arguments)] -pub async fn build_env_map( - pool: &PgPool, - folder: Option<&str>, - entry_type: Option<&str>, - name: Option<&str>, - tags: &[String], - only_fields: &[String], - prefix: &str, - master_key: &[u8; 32], - user_id: Option, -) -> Result> { - let entries = fetch_entries(pool, folder, entry_type, name, tags, None, None, user_id).await?; - if entries.is_empty() { - return Ok(HashMap::new()); - } - - let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); - let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; - - let mut combined: HashMap = HashMap::new(); - - for entry in &entries { - let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]); - let effective_prefix = env_prefix(entry, prefix); - - let fields: Vec<_> = if only_fields.is_empty() { - all_fields.iter().collect() - } else { - all_fields - .iter() - .filter(|f| only_fields.contains(&f.name)) - .collect() - }; - - for f in fields { - let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; - let seg = secret_name_to_env_segment(&f.name); - let key = format!("{}_{}", effective_prefix, seg); - if let Some(_old) = combined.insert(key.clone(), json_to_env_string(&decrypted)) { - anyhow::bail!( - "environment variable name collision after normalization: '{}' (secret '{}')", - key, - f.name - ); - } - } - } - - Ok(combined) -} - -/// Map a secret field name to an env key segment: `.` → `__`, `-` → `_`, then uppercase. -/// Avoids collisions between e.g. `db.password` and `db_password`. -fn secret_name_to_env_segment(name: &str) -> String { - name.replace('.', "__").replace('-', "_").to_uppercase() -} - -fn env_prefix(entry: &crate::models::Entry, prefix: &str) -> String { - let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_"); - if prefix.is_empty() { - name_part - } else { - let normalized = prefix.to_uppercase().replace(['-', '.', ' '], "_"); - let normalized = normalized.trim_end_matches('_'); - format!("{}_{}", normalized, name_part) - } -} - -fn json_to_env_string(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - Value::Null => String::new(), - other => other.to_string(), - } -} - -#[cfg(test)] -mod tests { - use serde_json::Value; - - use super::{env_prefix, secret_name_to_env_segment}; - use crate::models::Entry; - - #[test] - fn secret_name_env_segment_disambiguates_dot_from_underscore() { - assert_eq!(secret_name_to_env_segment("db.password"), "DB__PASSWORD"); - assert_eq!(secret_name_to_env_segment("db_password"), "DB_PASSWORD"); - assert_eq!(secret_name_to_env_segment("api-key"), "API_KEY"); - } - - #[test] - fn env_prefix_with_and_without_prefix() { - let entry = Entry { - id: uuid::Uuid::new_v4(), - user_id: None, - folder: "test".into(), - entry_type: "server".into(), - name: "my-server".into(), - notes: String::new(), - tags: vec![], - metadata: Value::Null, - version: 1, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - deleted_at: None, - }; - assert_eq!(env_prefix(&entry, ""), "MY_SERVER"); - assert_eq!(env_prefix(&entry, "ALIYUN"), "ALIYUN_MY_SERVER"); - assert_eq!(env_prefix(&entry, "aliyun_"), "ALIYUN_MY_SERVER"); - } -} diff --git a/crates/secrets-core/src/service/export.rs b/crates/secrets-core/src/service/export.rs deleted file mode 100644 index 463f15b..0000000 --- a/crates/secrets-core/src/service/export.rs +++ /dev/null @@ -1,144 +0,0 @@ -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use std::collections::{BTreeMap, HashMap}; -use uuid::Uuid; - -use crate::crypto; -use crate::models::{ExportData, ExportEntry, ExportFormat}; -use crate::service::search::{fetch_entries, fetch_secrets_for_entries}; - -pub struct ExportParams<'a> { - pub folder: Option<&'a str>, - pub entry_type: Option<&'a str>, - pub name: Option<&'a str>, - pub tags: &'a [String], - pub query: Option<&'a str>, - pub no_secrets: bool, - pub user_id: Option, -} - -pub async fn export( - pool: &PgPool, - params: ExportParams<'_>, - master_key: Option<&[u8; 32]>, -) -> Result { - let entries = fetch_entries( - pool, - params.folder, - params.entry_type, - params.name, - params.tags, - params.query, - None, - params.user_id, - ) - .await?; - - let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); - let secrets_map: HashMap> = if !params.no_secrets && !entry_ids.is_empty() { - fetch_secrets_for_entries(pool, &entry_ids).await? - } else { - HashMap::new() - }; - - let mut export_entries: Vec = Vec::with_capacity(entries.len()); - for entry in &entries { - let (secrets, secret_types) = if params.no_secrets { - (None, None) - } else { - let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]); - if fields.is_empty() { - (Some(BTreeMap::new()), Some(BTreeMap::new())) - } else { - let mk = master_key - .ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?; - let mut map = BTreeMap::new(); - let mut type_map = BTreeMap::new(); - for f in fields { - let decrypted = crypto::decrypt_json(mk, &f.encrypted)?; - map.insert(f.name.clone(), decrypted); - type_map.insert(f.name.clone(), f.secret_type.clone()); - } - (Some(map), Some(type_map)) - } - }; - - export_entries.push(ExportEntry { - name: entry.name.clone(), - folder: entry.folder.clone(), - entry_type: entry.entry_type.clone(), - notes: entry.notes.clone(), - tags: entry.tags.clone(), - metadata: entry.metadata.clone(), - secrets, - secret_types, - }); - } - - Ok(ExportData { - version: 1, - exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), - entries: export_entries, - }) -} - -pub async fn export_to_file( - pool: &PgPool, - params: ExportParams<'_>, - master_key: Option<&[u8; 32]>, - file_path: &str, - format_override: Option<&str>, -) -> Result { - let format = if let Some(f) = format_override { - f.parse::()? - } else { - ExportFormat::from_extension(file_path).unwrap_or(ExportFormat::Json) - }; - - let data = export(pool, params, master_key).await?; - let count = data.entries.len(); - let serialized = format.serialize(&data)?; - std::fs::write(file_path, &serialized)?; - Ok(count) -} - -pub async fn export_to_string( - pool: &PgPool, - params: ExportParams<'_>, - master_key: Option<&[u8; 32]>, - format: &str, -) -> Result { - let fmt = format.parse::()?; - let data = export(pool, params, master_key).await?; - fmt.serialize(&data) -} - -// ── Build helpers for re-encoding values as CLI-style entries ───────────────── - -pub fn build_meta_entries(metadata: &Value) -> Vec { - let mut entries = Vec::new(); - if let Some(obj) = metadata.as_object() { - for (k, v) in obj { - entries.push(value_to_kv_entry(k, v)); - } - } - entries -} - -pub fn build_secret_entries(secrets: Option<&BTreeMap>) -> Vec { - let mut entries = Vec::new(); - if let Some(map) = secrets { - for (k, v) in map { - entries.push(value_to_kv_entry(k, v)); - } - } - entries -} - -pub fn value_to_kv_entry(key: &str, value: &Value) -> String { - match value { - Value::String(s) => format!("{}={}", key, s), - other => format!("{}:={}", key, other), - } -} diff --git a/crates/secrets-core/src/service/get_secret.rs b/crates/secrets-core/src/service/get_secret.rs deleted file mode 100644 index 7da5f66..0000000 --- a/crates/secrets-core/src/service/get_secret.rs +++ /dev/null @@ -1,105 +0,0 @@ -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use std::collections::HashMap; -use uuid::Uuid; - -use crate::crypto; -use crate::error::AppError; -use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id}; - -/// Decrypt a single named field from an entry. -/// `folder` is optional; if omitted and multiple entries share the name, an error is returned. -pub async fn get_secret_field( - pool: &PgPool, - name: &str, - folder: Option<&str>, - field_name: &str, - master_key: &[u8; 32], - user_id: Option, -) -> Result { - let entry = resolve_entry(pool, name, folder, user_id).await?; - - let entry_ids = vec![entry.id]; - let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; - let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]); - - let field = fields - .iter() - .find(|f| f.name == field_name) - .ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?; - - crypto::decrypt_json(master_key, &field.encrypted) -} - -/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value. -/// `folder` is optional; if omitted and multiple entries share the name, an error is returned. -pub async fn get_all_secrets( - pool: &PgPool, - name: &str, - folder: Option<&str>, - master_key: &[u8; 32], - user_id: Option, -) -> Result> { - let entry = resolve_entry(pool, name, folder, user_id).await?; - - let entry_ids = vec![entry.id]; - let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; - let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]); - - let mut map = HashMap::new(); - for f in fields { - let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; - map.insert(f.name.clone(), decrypted); - } - Ok(map) -} - -/// Decrypt a single named field from an entry, located by its UUID. -pub async fn get_secret_field_by_id( - pool: &PgPool, - entry_id: Uuid, - field_name: &str, - master_key: &[u8; 32], - user_id: Option, -) -> Result { - resolve_entry_by_id(pool, entry_id, user_id) - .await - .map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?; - - let entry_ids = vec![entry_id]; - let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; - let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]); - - let field = fields - .iter() - .find(|f| f.name == field_name) - .ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?; - - crypto::decrypt_json(master_key, &field.encrypted) -} - -/// Decrypt all secret fields from an entry, located by its UUID. -/// Returns a map field_name → decrypted Value. -pub async fn get_all_secrets_by_id( - pool: &PgPool, - entry_id: Uuid, - master_key: &[u8; 32], - user_id: Option, -) -> Result> { - // Validate entry exists (and that it belongs to the requesting user) - resolve_entry_by_id(pool, entry_id, user_id) - .await - .map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?; - - let entry_ids = vec![entry_id]; - let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; - let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]); - - let mut map = HashMap::new(); - for f in fields { - let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; - map.insert(f.name.clone(), decrypted); - } - Ok(map) -} diff --git a/crates/secrets-core/src/service/history.rs b/crates/secrets-core/src/service/history.rs deleted file mode 100644 index 460eb68..0000000 --- a/crates/secrets-core/src/service/history.rs +++ /dev/null @@ -1,64 +0,0 @@ -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::service::search::resolve_entry; - -#[derive(Debug, serde::Serialize)] -pub struct HistoryEntry { - pub version: i64, - pub action: String, - pub created_at: String, -} - -/// Return version history for the entry identified by `name`. -/// `folder` is optional; if omitted and multiple entries share the name, an error is returned. -pub async fn run( - pool: &PgPool, - name: &str, - folder: Option<&str>, - limit: u32, - user_id: Option, -) -> Result> { - #[derive(sqlx::FromRow)] - struct Row { - version: i64, - action: String, - created_at: chrono::DateTime, - } - - let entry = resolve_entry(pool, name, folder, user_id).await?; - - let rows: Vec = sqlx::query_as( - "SELECT DISTINCT ON (version) version, action, created_at \ - FROM entries_history \ - WHERE entry_id = $1 \ - ORDER BY version DESC, id DESC \ - LIMIT $2", - ) - .bind(entry.id) - .bind(limit as i64) - .fetch_all(pool) - .await?; - - Ok(rows - .into_iter() - .map(|r| HistoryEntry { - version: r.version, - action: r.action, - created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), - }) - .collect()) -} - -pub async fn run_json( - pool: &PgPool, - name: &str, - folder: Option<&str>, - limit: u32, - user_id: Option, -) -> Result { - let entries = run(pool, name, folder, limit, user_id).await?; - Ok(serde_json::to_value(entries)?) -} diff --git a/crates/secrets-core/src/service/import.rs b/crates/secrets-core/src/service/import.rs deleted file mode 100644 index 7aeec75..0000000 --- a/crates/secrets-core/src/service/import.rs +++ /dev/null @@ -1,180 +0,0 @@ -use anyhow::Result; -use sqlx::PgPool; -use std::collections::HashMap; -use uuid::Uuid; - -use crate::models::ExportFormat; -use crate::service::add::{AddParams, run as add_run}; -use crate::service::export::{build_meta_entries, build_secret_entries}; - -#[derive(Debug, serde::Serialize)] -pub struct ImportSummary { - pub total: usize, - pub inserted: usize, - pub skipped: usize, - pub failed: usize, - pub dry_run: bool, -} - -pub struct ImportParams<'a> { - pub file: &'a str, - pub force: bool, - pub dry_run: bool, - pub user_id: Option, -} - -pub async fn run( - pool: &PgPool, - params: ImportParams<'_>, - master_key: &[u8; 32], -) -> Result { - let format = ExportFormat::from_extension(params.file)?; - let content = std::fs::read_to_string(params.file) - .map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", params.file, e))?; - let data = format.deserialize(&content)?; - - if data.version != 1 { - anyhow::bail!( - "Unsupported export version {}. Only version 1 is supported.", - data.version - ); - } - - let total = data.entries.len(); - let mut inserted = 0usize; - let mut skipped = 0usize; - let mut failed = 0usize; - - for entry in &data.entries { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM entries \ - WHERE folder = $1 AND name = $2 AND user_id IS NOT DISTINCT FROM $3)", - ) - .bind(&entry.folder) - .bind(&entry.name) - .bind(params.user_id) - .fetch_one(pool) - .await - .map_err(|e| { - anyhow::anyhow!( - "Failed to check entry existence for '{}': {}", - entry.name, - e - ) - })?; - - if exists && !params.force { - return Err(anyhow::anyhow!( - "Import aborted: conflict on '{}'", - entry.name - )); - } - - if params.dry_run { - if exists { - skipped += 1; - } else { - inserted += 1; - } - continue; - } - - let secret_entries = build_secret_entries(entry.secrets.as_ref()); - let meta_entries = build_meta_entries(&entry.metadata); - let secret_types_map: HashMap = entry - .secret_types - .as_ref() - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default(); - - match add_run( - pool, - AddParams { - name: &entry.name, - folder: &entry.folder, - entry_type: &entry.entry_type, - notes: &entry.notes, - tags: &entry.tags, - meta_entries: &meta_entries, - secret_entries: &secret_entries, - secret_types: &secret_types_map, - link_secret_names: &[], - user_id: params.user_id, - }, - master_key, - ) - .await - { - Ok(_) => { - inserted += 1; - } - Err(e) => { - tracing::error!( - name = entry.name, - error = %e, - "failed to import entry" - ); - failed += 1; - } - } - } - - if failed > 0 { - return Err(anyhow::anyhow!("{} record(s) failed to import", failed)); - } - - Ok(ImportSummary { - total, - inserted, - skipped, - failed, - dry_run: params.dry_run, - }) -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, HashMap}; - - use crate::models::ExportEntry; - - /// Mirrors the map built in `run` before `AddParams` (legacy files omit `secret_types`). - fn secret_types_for_add(entry: &ExportEntry) -> HashMap { - entry - .secret_types - .as_ref() - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default() - } - - #[test] - fn secret_types_three_kinds_round_trip_for_add_params() { - let mut types = BTreeMap::new(); - types.insert("p".into(), "password".into()); - types.insert("k".into(), "key".into()); - types.insert("t".into(), "text".into()); - let entry = ExportEntry { - name: "n".into(), - folder: "f".into(), - entry_type: "ty".into(), - notes: "".into(), - tags: vec![], - metadata: serde_json::json!({}), - secrets: Some(BTreeMap::new()), - secret_types: Some(types), - }; - let m = secret_types_for_add(&entry); - assert_eq!(m.get("p").map(String::as_str), Some("password")); - assert_eq!(m.get("k").map(String::as_str), Some("key")); - assert_eq!(m.get("t").map(String::as_str), Some("text")); - } - - #[test] - fn secret_types_absent_defaults_to_empty_map_like_legacy_export() { - let json = - r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{}}"#; - let entry: ExportEntry = serde_json::from_str(json).unwrap(); - assert!(entry.secret_types.is_none()); - assert!(secret_types_for_add(&entry).is_empty()); - } -} diff --git a/crates/secrets-core/src/service/mod.rs b/crates/secrets-core/src/service/mod.rs deleted file mode 100644 index 0d1b1b4..0000000 --- a/crates/secrets-core/src/service/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod add; -pub mod api_key; -pub mod audit_log; -pub mod delete; -pub mod env_map; -pub mod export; -pub mod get_secret; -pub mod history; -pub mod import; -pub mod relations; -pub mod rollback; -pub mod search; -pub mod update; -pub mod user; -pub mod util; diff --git a/crates/secrets-core/src/service/relations.rs b/crates/secrets-core/src/service/relations.rs deleted file mode 100644 index 9274cec..0000000 --- a/crates/secrets-core/src/service/relations.rs +++ /dev/null @@ -1,324 +0,0 @@ -use std::collections::{BTreeSet, HashMap}; - -use anyhow::Result; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::error::AppError; - -#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] -pub struct RelationEntrySummary { - pub id: Uuid, - pub folder: String, - #[serde(rename = "type")] - #[sqlx(rename = "type")] - pub entry_type: String, - pub name: String, -} - -#[derive(Debug, Clone, Default, serde::Serialize)] -pub struct EntryRelations { - pub parents: Vec, - pub children: Vec, -} - -pub async fn add_parent_relation( - pool: &PgPool, - parent_entry_id: Uuid, - child_entry_id: Uuid, - user_id: Option, -) -> Result<()> { - if parent_entry_id == child_entry_id { - return Err(AppError::Validation { - message: "entry cannot reference itself".to_string(), - } - .into()); - } - - let mut tx = pool.begin().await?; - validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?; - - let cycle_exists: bool = sqlx::query_scalar( - "WITH RECURSIVE descendants AS ( \ - SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \ - UNION \ - SELECT er.child_entry_id \ - FROM entry_relations er \ - JOIN descendants d ON d.child_entry_id = er.parent_entry_id \ - ) \ - SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)", - ) - .bind(child_entry_id) - .bind(parent_entry_id) - .fetch_one(&mut *tx) - .await?; - if cycle_exists { - tx.rollback().await?; - return Err(AppError::Validation { - message: "adding this relation would create a cycle".to_string(), - } - .into()); - } - - sqlx::query( - "INSERT INTO entry_relations (parent_entry_id, child_entry_id) \ - VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(parent_entry_id) - .bind(child_entry_id) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(()) -} - -pub async fn remove_parent_relation( - pool: &PgPool, - parent_entry_id: Uuid, - child_entry_id: Uuid, - user_id: Option, -) -> Result<()> { - let mut tx = pool.begin().await?; - validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?; - sqlx::query("DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2") - .bind(parent_entry_id) - .bind(child_entry_id) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(()) -} - -pub async fn set_parent_relations( - pool: &PgPool, - child_entry_id: Uuid, - parent_entry_ids: &[Uuid], - user_id: Option, -) -> Result<()> { - let deduped: Vec = parent_entry_ids - .iter() - .copied() - .collect::>() - .into_iter() - .collect(); - if deduped.contains(&child_entry_id) { - return Err(AppError::Validation { - message: "entry cannot reference itself".to_string(), - } - .into()); - } - - let mut tx = pool.begin().await?; - let mut validate_ids = Vec::with_capacity(deduped.len() + 1); - validate_ids.push(child_entry_id); - validate_ids.extend(deduped.iter().copied()); - validate_live_entries(&mut tx, &validate_ids, user_id).await?; - - let current_parent_ids: Vec = - sqlx::query_scalar("SELECT parent_entry_id FROM entry_relations WHERE child_entry_id = $1") - .bind(child_entry_id) - .fetch_all(&mut *tx) - .await?; - let current: BTreeSet = current_parent_ids.into_iter().collect(); - let target: BTreeSet = deduped.iter().copied().collect(); - - for parent_id in current.difference(&target) { - sqlx::query( - "DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2", - ) - .bind(*parent_id) - .bind(child_entry_id) - .execute(&mut *tx) - .await?; - } - - for parent_id in target.difference(¤t) { - let cycle_exists: bool = sqlx::query_scalar( - "WITH RECURSIVE descendants AS ( \ - SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \ - UNION \ - SELECT er.child_entry_id \ - FROM entry_relations er \ - JOIN descendants d ON d.child_entry_id = er.parent_entry_id \ - ) \ - SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)", - ) - .bind(child_entry_id) - .bind(*parent_id) - .fetch_one(&mut *tx) - .await?; - if cycle_exists { - tx.rollback().await?; - return Err(AppError::Validation { - message: "adding this relation would create a cycle".to_string(), - } - .into()); - } - - sqlx::query( - "INSERT INTO entry_relations (parent_entry_id, child_entry_id) VALUES ($1, $2) \ - ON CONFLICT DO NOTHING", - ) - .bind(*parent_id) - .bind(child_entry_id) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - Ok(()) -} - -pub async fn get_relations_for_entries( - pool: &PgPool, - entry_ids: &[Uuid], - user_id: Option, -) -> Result> { - if entry_ids.is_empty() { - return Ok(HashMap::new()); - } - - #[derive(sqlx::FromRow)] - struct ParentRow { - owner_entry_id: Uuid, - id: Uuid, - folder: String, - #[sqlx(rename = "type")] - entry_type: String, - name: String, - } - - #[derive(sqlx::FromRow)] - struct ChildRow { - owner_entry_id: Uuid, - id: Uuid, - folder: String, - #[sqlx(rename = "type")] - entry_type: String, - name: String, - } - - let (parents, children): (Vec, Vec) = if let Some(uid) = user_id { - let parents = sqlx::query_as( - "SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \ - FROM entry_relations er \ - JOIN entries p ON p.id = er.parent_entry_id \ - JOIN entries c ON c.id = er.child_entry_id \ - WHERE er.child_entry_id = ANY($1) \ - AND p.user_id = $2 AND c.user_id = $2 \ - AND p.deleted_at IS NULL AND c.deleted_at IS NULL \ - ORDER BY er.child_entry_id, p.name ASC", - ) - .bind(entry_ids) - .bind(uid) - .fetch_all(pool); - let children = sqlx::query_as( - "SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \ - FROM entry_relations er \ - JOIN entries c ON c.id = er.child_entry_id \ - JOIN entries p ON p.id = er.parent_entry_id \ - WHERE er.parent_entry_id = ANY($1) \ - AND p.user_id = $2 AND c.user_id = $2 \ - AND p.deleted_at IS NULL AND c.deleted_at IS NULL \ - ORDER BY er.parent_entry_id, c.name ASC", - ) - .bind(entry_ids) - .bind(uid) - .fetch_all(pool); - (parents.await?, children.await?) - } else { - let parents = sqlx::query_as( - "SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \ - FROM entry_relations er \ - JOIN entries p ON p.id = er.parent_entry_id \ - JOIN entries c ON c.id = er.child_entry_id \ - WHERE er.child_entry_id = ANY($1) \ - AND p.user_id IS NULL AND c.user_id IS NULL \ - AND p.deleted_at IS NULL AND c.deleted_at IS NULL \ - ORDER BY er.child_entry_id, p.name ASC", - ) - .bind(entry_ids) - .fetch_all(pool); - let children = sqlx::query_as( - "SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \ - FROM entry_relations er \ - JOIN entries c ON c.id = er.child_entry_id \ - JOIN entries p ON p.id = er.parent_entry_id \ - WHERE er.parent_entry_id = ANY($1) \ - AND p.user_id IS NULL AND c.user_id IS NULL \ - AND p.deleted_at IS NULL AND c.deleted_at IS NULL \ - ORDER BY er.parent_entry_id, c.name ASC", - ) - .bind(entry_ids) - .fetch_all(pool); - (parents.await?, children.await?) - }; - - let mut map: HashMap = entry_ids - .iter() - .copied() - .map(|id| (id, EntryRelations::default())) - .collect(); - - for row in parents { - map.entry(row.owner_entry_id) - .or_default() - .parents - .push(RelationEntrySummary { - id: row.id, - folder: row.folder, - entry_type: row.entry_type, - name: row.name, - }); - } - - for row in children { - map.entry(row.owner_entry_id) - .or_default() - .children - .push(RelationEntrySummary { - id: row.id, - folder: row.folder, - entry_type: row.entry_type, - name: row.name, - }); - } - - Ok(map) -} - -async fn validate_live_entries( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - entry_ids: &[Uuid], - user_id: Option, -) -> Result<()> { - let unique_ids: Vec = entry_ids - .iter() - .copied() - .collect::>() - .into_iter() - .collect(); - let live_count: i64 = if let Some(uid) = user_id { - sqlx::query_scalar( - "SELECT COUNT(*)::bigint FROM entries \ - WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL", - ) - .bind(&unique_ids) - .bind(uid) - .fetch_one(&mut **tx) - .await? - } else { - sqlx::query_scalar( - "SELECT COUNT(*)::bigint FROM entries \ - WHERE id = ANY($1) AND user_id IS NULL AND deleted_at IS NULL", - ) - .bind(&unique_ids) - .fetch_one(&mut **tx) - .await? - }; - - if live_count != unique_ids.len() as i64 { - return Err(AppError::NotFoundEntry.into()); - } - Ok(()) -} diff --git a/crates/secrets-core/src/service/rollback.rs b/crates/secrets-core/src/service/rollback.rs deleted file mode 100644 index 33168b6..0000000 --- a/crates/secrets-core/src/service/rollback.rs +++ /dev/null @@ -1,343 +0,0 @@ -use std::collections::HashSet; - -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::db; -use crate::error::AppError; -use crate::models::EntryWriteRow; - -#[derive(Debug, serde::Serialize)] -pub struct RollbackResult { - pub name: String, - pub folder: String, - #[serde(rename = "type")] - pub entry_type: String, - pub restored_version: i64, -} - -/// Roll back entry `name` to `to_version` (or the most recent snapshot if None). -pub async fn run( - pool: &PgPool, - entry_id: Uuid, - to_version: Option, - user_id: Option, -) -> Result { - #[derive(sqlx::FromRow)] - struct EntryHistoryRow { - folder: String, - #[sqlx(rename = "type")] - entry_type: String, - name: String, - version: i64, - action: String, - tags: Vec, - metadata: Value, - } - - let mut tx = pool.begin().await?; - - let live: Option = if let Some(uid) = user_id { - sqlx::query_as( - "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ - WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE", - ) - .bind(entry_id) - .bind(uid) - .fetch_optional(&mut *tx) - .await? - } else { - sqlx::query_as( - "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ - WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL FOR UPDATE", - ) - .bind(entry_id) - .fetch_optional(&mut *tx) - .await? - }; - - let lr = live.ok_or(AppError::NotFoundEntry)?; - - let snap: Option = if let Some(ver) = to_version { - sqlx::query_as( - "SELECT folder, type, name, version, action, tags, metadata \ - FROM entries_history \ - WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1", - ) - .bind(entry_id) - .bind(ver) - .fetch_optional(&mut *tx) - .await? - } else { - sqlx::query_as( - "SELECT folder, type, name, version, action, tags, metadata \ - FROM entries_history \ - WHERE entry_id = $1 ORDER BY id DESC LIMIT 1", - ) - .bind(entry_id) - .fetch_optional(&mut *tx) - .await? - }; - - let snap = snap.ok_or_else(|| { - anyhow::anyhow!( - "No history found for entry '{}'{}.", - lr.name, - to_version - .map(|v| format!(" at version {}", v)) - .unwrap_or_default() - ) - })?; - - let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata); - let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata); - - let live_entry_id = { - let history_metadata = - match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); - lr.metadata.clone() - } - }; - - if let Err(e) = db::snapshot_entry_history( - &mut tx, - db::EntrySnapshotParams { - entry_id: lr.id, - user_id, - folder: &lr.folder, - entry_type: &lr.entry_type, - name: &lr.name, - version: lr.version, - action: "rollback", - tags: &lr.tags, - metadata: &history_metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry before rollback"); - } - - #[derive(sqlx::FromRow)] - struct LiveField { - id: Uuid, - name: String, - encrypted: Vec, - } - let live_fields: Vec = sqlx::query_as( - "SELECT s.id, s.name, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1", - ) - .bind(lr.id) - .fetch_all(&mut *tx) - .await?; - - for f in &live_fields { - if let Err(e) = db::snapshot_secret_history( - &mut tx, - db::SecretSnapshotParams { - secret_id: f.id, - name: &f.name, - encrypted: &f.encrypted, - action: "rollback", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret field before rollback"); - } - } - - sqlx::query( - "UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \ - version = version + 1, updated_at = NOW() WHERE id = $7", - ) - .bind(&snap.folder) - .bind(&snap.entry_type) - .bind(&snap.name) - .bind(&lr.notes) - .bind(&snap.tags) - .bind(&snap_metadata) - .bind(lr.id) - .execute(&mut *tx) - .await?; - - lr.id - }; - - if let Some(secret_snapshot) = snap_secret_snapshot { - restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?; - } - - crate::audit::log_tx( - &mut tx, - user_id, - "rollback", - &snap.folder, - &snap.entry_type, - &snap.name, - serde_json::json!({ - "entry_id": entry_id, - "restored_version": snap.version, - "original_action": snap.action, - }), - ) - .await; - - tx.commit().await?; - - Ok(RollbackResult { - name: snap.name, - folder: snap.folder, - entry_type: snap.entry_type, - restored_version: snap.version, - }) -} - -async fn restore_entry_secrets( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - entry_id: Uuid, - user_id: Option, - snapshot: &[db::EntrySecretSnapshot], -) -> Result<()> { - #[derive(sqlx::FromRow)] - struct LinkedSecret { - id: Uuid, - name: String, - encrypted: Vec, - } - - let linked: Vec = sqlx::query_as( - "SELECT s.id, s.name, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1", - ) - .bind(entry_id) - .fetch_all(&mut **tx) - .await?; - - let target_names: HashSet<&str> = snapshot.iter().map(|s| s.name.as_str()).collect(); - - for s in &linked { - if target_names.contains(s.name.as_str()) { - continue; - } - if let Err(e) = db::snapshot_secret_history( - tx, - db::SecretSnapshotParams { - secret_id: s.id, - name: &s.name, - encrypted: &s.encrypted, - action: "rollback", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret before rollback unlink"); - } - - sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") - .bind(entry_id) - .bind(s.id) - .execute(&mut **tx) - .await?; - - sqlx::query( - "DELETE FROM secrets s \ - WHERE s.id = $1 \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", - ) - .bind(s.id) - .execute(&mut **tx) - .await?; - } - - for snap in snapshot { - let encrypted = ::hex::decode(&snap.encrypted_hex).map_err(|e| { - anyhow::anyhow!("invalid secret snapshot data for '{}': {}", snap.name, e) - })?; - - #[derive(sqlx::FromRow)] - struct ExistingSecret { - id: Uuid, - encrypted: Vec, - } - - let existing: Option = if let Some(uid) = user_id { - sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 AND name = $2") - .bind(uid) - .bind(&snap.name) - .fetch_optional(&mut **tx) - .await? - } else { - sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id IS NULL AND name = $1") - .bind(&snap.name) - .fetch_optional(&mut **tx) - .await? - }; - - let secret_id = if let Some(ex) = existing { - if ex.encrypted != encrypted - && let Err(e) = db::snapshot_secret_history( - tx, - db::SecretSnapshotParams { - secret_id: ex.id, - name: &snap.name, - encrypted: &ex.encrypted, - action: "rollback", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret before rollback restore"); - } - sqlx::query( - "UPDATE secrets SET type = $1, encrypted = $2, version = version + 1, updated_at = NOW() \ - WHERE id = $3", - ) - .bind(&snap.secret_type) - .bind(&encrypted) - .bind(ex.id) - .execute(&mut **tx) - .await?; - ex.id - } else if let Some(uid) = user_id { - sqlx::query_scalar( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", - ) - .bind(uid) - .bind(&snap.name) - .bind(&snap.secret_type) - .bind(&encrypted) - .fetch_one(&mut **tx) - .await? - } else { - sqlx::query_scalar( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, $2, $3) RETURNING id", - ) - .bind(&snap.name) - .bind(&snap.secret_type) - .bind(&encrypted) - .fetch_one(&mut **tx) - .await? - }; - - sqlx::query( - "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(entry_id) - .bind(secret_id) - .execute(&mut **tx) - .await?; - } - - Ok(()) -} diff --git a/crates/secrets-core/src/service/search.rs b/crates/secrets-core/src/service/search.rs deleted file mode 100644 index 5d8368a..0000000 --- a/crates/secrets-core/src/service/search.rs +++ /dev/null @@ -1,421 +0,0 @@ -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use std::collections::HashMap; -use uuid::Uuid; - -use crate::models::{Entry, SecretField}; - -pub const FETCH_ALL_LIMIT: u32 = 10_000; - -/// Build an ILIKE pattern for fuzzy matching, escaping `%` and `_` literals. -pub fn ilike_pattern(value: &str) -> String { - format!( - "%{}%", - value - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") - ) -} - -pub struct SearchParams<'a> { - pub folder: Option<&'a str>, - pub entry_type: Option<&'a str>, - pub name: Option<&'a str>, - /// Fuzzy match on `entries.name` only (ILIKE with escaped `%`/`_`). - pub name_query: Option<&'a str>, - pub tags: &'a [String], - pub query: Option<&'a str>, - pub metadata_query: Option<&'a str>, - pub sort: &'a str, - pub limit: u32, - pub offset: u32, - /// Multi-user: filter by this user_id. None = single-user / no filter. - pub user_id: Option, -} - -#[derive(Debug, serde::Serialize)] -pub struct SearchResult { - pub entries: Vec, - pub secret_schemas: HashMap>, -} - -/// List `entries` rows matching params (paged, ordered per `params.sort`). -/// Does not read the `secrets` table. -pub async fn list_entries(pool: &PgPool, params: SearchParams<'_>) -> Result> { - fetch_entries_paged(pool, ¶ms).await -} - -/// Count `entries` rows matching the same filters as [`list_entries`] (ignores `sort` / `limit` / `offset`). -/// Does not read the `secrets` table. -pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result { - let (where_clause, _) = entry_where_clause_and_next_idx(a); - let sql = format!("SELECT COUNT(*)::bigint FROM entries {where_clause}"); - let mut q = sqlx::query_scalar::<_, i64>(&sql); - if let Some(uid) = a.user_id { - q = q.bind(uid); - } - if let Some(v) = a.folder { - q = q.bind(v); - } - if let Some(v) = a.entry_type { - q = q.bind(v); - } - if let Some(v) = a.name { - q = q.bind(v); - } - if let Some(v) = a.name_query { - let pattern = ilike_pattern(v); - q = q.bind(pattern); - } - for tag in a.tags { - q = q.bind(tag); - } - if let Some(v) = a.query { - let pattern = ilike_pattern(v); - q = q.bind(pattern); - } - if let Some(v) = a.metadata_query { - let pattern = ilike_pattern(v); - q = q.bind(pattern); - } - let n = q.fetch_one(pool).await?; - Ok(n) -} - -/// Shared WHERE clause and the next `$n` index (for LIMIT/OFFSET in paged queries). -fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) { - let mut conditions: Vec = Vec::new(); - let mut idx: i32 = 1; - - if a.user_id.is_some() { - conditions.push(format!("user_id = ${}", idx)); - idx += 1; - } else { - conditions.push("user_id IS NULL".to_string()); - } - conditions.push("deleted_at IS NULL".to_string()); - - if a.folder.is_some() { - conditions.push(format!("folder = ${}", idx)); - idx += 1; - } - if a.entry_type.is_some() { - conditions.push(format!("type = ${}", idx)); - idx += 1; - } - if a.name.is_some() { - conditions.push(format!("name = ${}", idx)); - idx += 1; - } - if a.name_query.is_some() { - conditions.push(format!("name ILIKE ${} ESCAPE '\\'", idx)); - idx += 1; - } - if !a.tags.is_empty() { - let placeholders: Vec = a - .tags - .iter() - .map(|_| { - let p = format!("${}", idx); - idx += 1; - p - }) - .collect(); - conditions.push(format!( - "tags @> ARRAY[{}]::text[]", - placeholders.join(", ") - )); - } - if a.query.is_some() { - conditions.push(format!( - "(name ILIKE ${i} ESCAPE '\\' OR folder ILIKE ${i} ESCAPE '\\' \ - OR type ILIKE ${i} ESCAPE '\\' OR notes ILIKE ${i} ESCAPE '\\' \ - OR metadata::text ILIKE ${i} ESCAPE '\\' \ - OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))", - i = idx - )); - idx += 1; - } - if a.metadata_query.is_some() { - conditions.push(format!( - "EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \ - WHERE (val #>> '{{}}') ILIKE ${} ESCAPE '\\')", - idx - )); - idx += 1; - } - - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!("WHERE {}", conditions.join(" AND ")) - }; - (where_clause, idx) -} - -pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result { - let entries = fetch_entries_paged(pool, ¶ms).await?; - let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); - let secret_schemas = if !entry_ids.is_empty() { - fetch_secrets_for_entries(pool, &entry_ids).await? - } else { - HashMap::new() - }; - Ok(SearchResult { - entries, - secret_schemas, - }) -} - -/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT. -#[allow(clippy::too_many_arguments)] -pub async fn fetch_entries( - pool: &PgPool, - folder: Option<&str>, - entry_type: Option<&str>, - name: Option<&str>, - tags: &[String], - query: Option<&str>, - metadata_query: Option<&str>, - user_id: Option, -) -> Result> { - let params = SearchParams { - folder, - entry_type, - name, - name_query: None, - tags, - query, - metadata_query, - sort: "name", - limit: FETCH_ALL_LIMIT, - offset: 0, - user_id, - }; - list_entries(pool, params).await -} - -async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result> { - let (where_clause, idx) = entry_where_clause_and_next_idx(a); - - let order = match a.sort { - "updated" => "updated_at DESC", - "created" => "created_at DESC", - _ => "name ASC", - }; - - let limit_idx = idx; - let offset_idx = idx + 1; - - let sql = format!( - "SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \ - created_at, updated_at, deleted_at \ - FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}" - ); - - let mut q = sqlx::query_as::<_, EntryRaw>(&sql); - if let Some(uid) = a.user_id { - q = q.bind(uid); - } - if let Some(v) = a.folder { - q = q.bind(v); - } - if let Some(v) = a.entry_type { - q = q.bind(v); - } - if let Some(v) = a.name { - q = q.bind(v); - } - if let Some(v) = a.name_query { - let pattern = ilike_pattern(v); - q = q.bind(pattern); - } - for tag in a.tags { - q = q.bind(tag); - } - if let Some(v) = a.query { - let pattern = ilike_pattern(v); - q = q.bind(pattern); - } - if let Some(v) = a.metadata_query { - let pattern = ilike_pattern(v); - q = q.bind(pattern); - } - q = q.bind(a.limit as i64).bind(a.offset as i64); - - let rows = q.fetch_all(pool).await?; - Ok(rows.into_iter().map(Entry::from).collect()) -} - -/// Fetch all secret fields (including encrypted bytes) for a set of entry ids. -pub async fn fetch_secrets_for_entries( - pool: &PgPool, - entry_ids: &[Uuid], -) -> Result>> { - if entry_ids.is_empty() { - return Ok(HashMap::new()); - } - let fields: Vec = sqlx::query_as( - "SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = ANY($1) \ - ORDER BY es.entry_id, es.sort_order, s.name", - ) - .bind(entry_ids) - .fetch_all(pool) - .await?; - - let mut map: HashMap> = HashMap::new(); - for f in fields { - let entry_id = f.entry_id; - map.entry(entry_id).or_default().push(f.secret()); - } - Ok(map) -} - -/// Resolve exactly one entry by its UUID primary key. -/// -/// Returns an error if the entry does not exist or does not belong to the given user. -pub async fn resolve_entry_by_id( - pool: &PgPool, - id: Uuid, - user_id: Option, -) -> Result { - let row: Option = if let Some(uid) = user_id { - sqlx::query_as( - "SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \ - created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL", - ) - .bind(id) - .bind(uid) - .fetch_optional(pool) - .await? - } else { - sqlx::query_as( - "SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \ - created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL", - ) - .bind(id) - .fetch_optional(pool) - .await? - }; - row.map(Entry::from) - .ok_or_else(|| anyhow::anyhow!("Entry with id '{}' not found", id)) -} - -/// Resolve exactly one entry by name, with optional folder for disambiguation. -/// -/// - If `folder` is provided: exact `(folder, name)` match. -/// - If `folder` is None and exactly one entry matches: returns it. -/// - If `folder` is None and multiple entries match: returns an error listing -/// the folders and asking the caller to specify one. -pub async fn resolve_entry( - pool: &PgPool, - name: &str, - folder: Option<&str>, - user_id: Option, -) -> Result { - let entries = fetch_entries(pool, folder, None, Some(name), &[], None, None, user_id).await?; - match entries.len() { - 0 => { - if let Some(f) = folder { - anyhow::bail!("Not found: '{}' in folder '{}'", name, f) - } else { - anyhow::bail!("Not found: '{}'", name) - } - } - 1 => entries - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("internal: resolve_entry result vanished")), - _ => { - let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect(); - anyhow::bail!( - "Ambiguous: {} entries named '{}' found in folders: [{}]. \ - Specify 'folder' to disambiguate.", - entries.len(), - name, - folders.join(", ") - ) - } - } -} - -// ── Internal raw row (because user_id is nullable in DB) ───────────────────── -#[derive(sqlx::FromRow)] -struct EntryRaw { - id: Uuid, - user_id: Option, - folder: String, - #[sqlx(rename = "type")] - entry_type: String, - name: String, - notes: String, - tags: Vec, - metadata: Value, - version: i64, - created_at: chrono::DateTime, - updated_at: chrono::DateTime, - deleted_at: Option>, -} - -impl From for Entry { - fn from(r: EntryRaw) -> Self { - Entry { - id: r.id, - user_id: r.user_id, - folder: r.folder, - entry_type: r.entry_type, - name: r.name, - notes: r.notes, - tags: r.tags, - metadata: r.metadata, - version: r.version, - created_at: r.created_at, - updated_at: r.updated_at, - deleted_at: r.deleted_at, - } - } -} - -#[derive(sqlx::FromRow)] -struct EntrySecretRow { - entry_id: Uuid, - id: Uuid, - user_id: Option, - name: String, - #[sqlx(rename = "type")] - secret_type: String, - encrypted: Vec, - version: i64, - created_at: chrono::DateTime, - updated_at: chrono::DateTime, -} - -impl EntrySecretRow { - fn secret(self) -> SecretField { - SecretField { - id: self.id, - user_id: self.user_id, - name: self.name, - secret_type: self.secret_type, - encrypted: self.encrypted, - version: self.version, - created_at: self.created_at, - updated_at: self.updated_at, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ilike_pattern_escapes_backslash_percent_and_underscore() { - assert_eq!(ilike_pattern(r"hello\_100%"), r"%hello\\\_100\%%"); - } -} diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs deleted file mode 100644 index 69b8de3..0000000 --- a/crates/secrets-core/src/service/update.rs +++ /dev/null @@ -1,562 +0,0 @@ -use anyhow::Result; -use serde_json::{Map, Value}; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::crypto; -use crate::db; -use crate::error::{AppError, DbErrorContext}; -use crate::models::{EntryRow, EntryWriteRow}; -use crate::service::add::{ - collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path, - parse_kv, remove_path, -}; -use crate::service::util::user_scope_condition; - -#[derive(Debug, serde::Serialize)] -pub struct UpdateResult { - pub name: String, - pub folder: String, - #[serde(rename = "type")] - pub entry_type: String, - pub add_tags: Vec, - pub remove_tags: Vec, - pub meta_keys: Vec, - pub remove_meta: Vec, - pub secret_keys: Vec, - pub remove_secrets: Vec, - pub linked_secrets: Vec, - pub unlinked_secrets: Vec, -} - -pub struct UpdateParams<'a> { - pub name: &'a str, - /// Optional folder for disambiguation when multiple entries share the same name. - pub folder: Option<&'a str>, - pub notes: Option<&'a str>, - pub add_tags: &'a [String], - pub remove_tags: &'a [String], - pub meta_entries: &'a [String], - pub remove_meta: &'a [String], - pub secret_entries: &'a [String], - pub secret_types: &'a std::collections::HashMap, - pub remove_secrets: &'a [String], - pub link_secret_names: &'a [String], - pub unlink_secret_names: &'a [String], - pub user_id: Option, -} - -pub async fn run( - pool: &PgPool, - params: UpdateParams<'_>, - master_key: &[u8; 32], -) -> Result { - if params.name.chars().count() > 256 { - anyhow::bail!("name must be at most 256 characters"); - } - let mut tx = pool.begin().await?; - - // Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity. - let mut idx = 1i32; - let user_cond = user_scope_condition(params.user_id, &mut idx); - let mut conditions = vec![user_cond]; - if params.folder.is_some() { - conditions.push(format!("folder = ${}", idx)); - idx += 1; - } - conditions.push(format!("name = ${}", idx)); - let sql = format!( - "SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \ - WHERE {} AND deleted_at IS NULL FOR UPDATE", - conditions.join(" AND ") - ); - let mut q = sqlx::query_as::<_, EntryRow>(&sql); - if let Some(uid) = params.user_id { - q = q.bind(uid); - } - if let Some(folder) = params.folder { - q = q.bind(folder); - } - q = q.bind(params.name); - let rows = q.fetch_all(&mut *tx).await?; - - let row = match rows.len() { - 0 => { - tx.rollback().await?; - return Err(AppError::NotFoundEntry.into()); - } - 1 => rows - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?, - _ => { - tx.rollback().await?; - let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect(); - anyhow::bail!( - "Ambiguous: {} entries named '{}' found in folders: [{}]. \ - Specify 'folder' to disambiguate.", - rows.len(), - params.name, - folders.join(", ") - ) - } - }; - - let history_metadata = - match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); - row.metadata.clone() - } - }; - - if let Err(e) = db::snapshot_entry_history( - &mut tx, - db::EntrySnapshotParams { - entry_id: row.id, - user_id: params.user_id, - folder: &row.folder, - entry_type: &row.entry_type, - name: params.name, - version: row.version, - action: "update", - tags: &row.tags, - metadata: &history_metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry history before update"); - } - - let mut tags: Vec = row.tags.clone(); - for t in params.add_tags { - if !tags.contains(t) { - tags.push(t.clone()); - } - } - tags.retain(|t| !params.remove_tags.contains(t)); - - let mut meta_map: Map = match row.metadata.clone() { - Value::Object(m) => m, - _ => Map::new(), - }; - for entry in params.meta_entries { - let (path, value) = parse_kv(entry)?; - insert_path(&mut meta_map, &path, value)?; - } - for key in params.remove_meta { - let path = parse_key_path(key)?; - remove_path(&mut meta_map, &path)?; - } - let metadata = Value::Object(meta_map); - - let new_notes = params.notes.unwrap_or(&row.notes); - - let result = sqlx::query( - "UPDATE entries SET tags = $1, metadata = $2, notes = $3, \ - version = version + 1, updated_at = NOW() \ - WHERE id = $4 AND version = $5", - ) - .bind(&tags) - .bind(&metadata) - .bind(new_notes) - .bind(row.id) - .bind(row.version) - .execute(&mut *tx) - .await?; - - if result.rows_affected() == 0 { - tx.rollback().await?; - return Err(AppError::ConcurrentModification.into()); - } - - for entry in params.secret_entries { - let (path, field_value) = parse_kv(entry)?; - let flat = flatten_json_fields("", &{ - let mut m = Map::new(); - insert_path(&mut m, &path, field_value)?; - Value::Object(m) - }); - - for (field_name, fv) in &flat { - let encrypted = crypto::encrypt_json(master_key, fv)?; - - #[derive(sqlx::FromRow)] - struct ExistingField { - id: Uuid, - encrypted: Vec, - } - let ef: Option = sqlx::query_as( - "SELECT s.id, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1 AND s.name = $2", - ) - .bind(row.id) - .bind(field_name) - .fetch_optional(&mut *tx) - .await?; - - if let Some(ef) = &ef - && let Err(e) = db::snapshot_secret_history( - &mut tx, - db::SecretSnapshotParams { - secret_id: ef.id, - name: field_name, - encrypted: &ef.encrypted, - action: "update", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret field history"); - } - - if let Some(ef) = ef { - sqlx::query( - "UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2", - ) - .bind(&encrypted) - .bind(ef.id) - .execute(&mut *tx) - .await?; - } else { - let secret_type = params - .secret_types - .get(field_name) - .map(|s| s.as_str()) - .unwrap_or("text"); - let secret_id: Uuid = sqlx::query_scalar( - "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", - ) - .bind(params.user_id) - .bind(field_name.to_string()) - .bind(secret_type) - .bind(&encrypted) - .fetch_one(&mut *tx) - .await - .map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?; - sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)") - .bind(row.id) - .bind(secret_id) - .execute(&mut *tx) - .await?; - } - } - } - - for key in params.remove_secrets { - let path = parse_key_path(key)?; - let field_name = path.join("."); - - #[derive(sqlx::FromRow)] - struct FieldToDelete { - id: Uuid, - encrypted: Vec, - } - let field: Option = sqlx::query_as( - "SELECT s.id, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1 AND s.name = $2", - ) - .bind(row.id) - .bind(&field_name) - .fetch_optional(&mut *tx) - .await?; - - if let Some(f) = field { - if let Err(e) = db::snapshot_secret_history( - &mut tx, - db::SecretSnapshotParams { - secret_id: f.id, - name: &field_name, - encrypted: &f.encrypted, - action: "delete", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret field history before delete"); - } - sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") - .bind(row.id) - .bind(f.id) - .execute(&mut *tx) - .await?; - sqlx::query( - "DELETE FROM secrets s \ - WHERE s.id = $1 \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", - ) - .bind(f.id) - .execute(&mut *tx) - .await?; - } - } - - // Link existing secrets by name - let mut linked_secrets = Vec::new(); - for link_name in params.link_secret_names { - let link_name = link_name.trim(); - if link_name.is_empty() { - anyhow::bail!("link_secret_names contains an empty name"); - } - let secret_ids: Vec = if let Some(uid) = params.user_id { - sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2") - .bind(uid) - .bind(link_name) - .fetch_all(&mut *tx) - .await? - } else { - sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1") - .bind(link_name) - .fetch_all(&mut *tx) - .await? - }; - - match secret_ids.len() { - 0 => anyhow::bail!("Not found: secret named '{}'", link_name), - 1 => { - sqlx::query( - "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(row.id) - .bind(secret_ids[0]) - .execute(&mut *tx) - .await?; - linked_secrets.push(link_name.to_string()); - } - n => anyhow::bail!( - "Ambiguous: {} secrets named '{}' found. Please deduplicate names first.", - n, - link_name - ), - } - } - - // Unlink secrets by name - let mut unlinked_secrets = Vec::new(); - for unlink_name in params.unlink_secret_names { - let unlink_name = unlink_name.trim(); - if unlink_name.is_empty() { - continue; - } - - #[derive(sqlx::FromRow)] - struct SecretToUnlink { - id: Uuid, - encrypted: Vec, - } - let secret: Option = sqlx::query_as( - "SELECT s.id, s.encrypted \ - FROM entry_secrets es \ - JOIN secrets s ON s.id = es.secret_id \ - WHERE es.entry_id = $1 AND s.name = $2", - ) - .bind(row.id) - .bind(unlink_name) - .fetch_optional(&mut *tx) - .await?; - - if let Some(s) = secret { - if let Err(e) = db::snapshot_secret_history( - &mut tx, - db::SecretSnapshotParams { - secret_id: s.id, - name: unlink_name, - encrypted: &s.encrypted, - action: "delete", - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot secret field history before unlink"); - } - sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") - .bind(row.id) - .bind(s.id) - .execute(&mut *tx) - .await?; - sqlx::query( - "DELETE FROM secrets s \ - WHERE s.id = $1 \ - AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", - ) - .bind(s.id) - .execute(&mut *tx) - .await?; - unlinked_secrets.push(unlink_name.to_string()); - } - } - - let meta_keys = collect_key_paths(params.meta_entries)?; - let remove_meta_keys = collect_field_paths(params.remove_meta)?; - let secret_keys = collect_key_paths(params.secret_entries)?; - let remove_secret_keys = collect_field_paths(params.remove_secrets)?; - - crate::audit::log_tx( - &mut tx, - params.user_id, - "update", - &row.folder, - &row.entry_type, - params.name, - serde_json::json!({ - "add_tags": params.add_tags, - "remove_tags": params.remove_tags, - "meta_keys": meta_keys, - "remove_meta": remove_meta_keys, - "secret_keys": secret_keys, - "remove_secrets": remove_secret_keys, - "linked_secrets": linked_secrets, - "unlinked_secrets": unlinked_secrets, - }), - ) - .await; - - tx.commit().await?; - - Ok(UpdateResult { - name: params.name.to_string(), - folder: row.folder.clone(), - entry_type: row.entry_type.clone(), - add_tags: params.add_tags.to_vec(), - remove_tags: params.remove_tags.to_vec(), - meta_keys, - remove_meta: remove_meta_keys, - secret_keys, - remove_secrets: remove_secret_keys, - linked_secrets, - unlinked_secrets, - }) -} - -/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match). -/// Does not read or modify `secrets` rows. -pub struct UpdateEntryFieldsByIdParams<'a> { - pub folder: &'a str, - pub entry_type: &'a str, - pub name: &'a str, - pub notes: &'a str, - pub tags: &'a [String], - pub metadata: &'a serde_json::Value, -} - -pub async fn update_fields_by_id( - pool: &PgPool, - entry_id: Uuid, - user_id: Uuid, - params: UpdateEntryFieldsByIdParams<'_>, -) -> Result<()> { - if params.folder.chars().count() > 128 { - anyhow::bail!("folder must be at most 128 characters"); - } - if params.entry_type.chars().count() > 64 { - anyhow::bail!("type must be at most 64 characters"); - } - if params.name.chars().count() > 256 { - anyhow::bail!("name must be at most 256 characters"); - } - - let mut tx = pool.begin().await?; - - let row: Option = sqlx::query_as( - "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ - WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE", - ) - .bind(entry_id) - .bind(user_id) - .fetch_optional(&mut *tx) - .await?; - - let row = match row { - Some(r) => r, - None => { - tx.rollback().await?; - return Err(AppError::NotFoundEntry.into()); - } - }; - - let history_metadata = - match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); - row.metadata.clone() - } - }; - - if let Err(e) = db::snapshot_entry_history( - &mut tx, - db::EntrySnapshotParams { - entry_id: row.id, - user_id: Some(user_id), - folder: &row.folder, - entry_type: &row.entry_type, - name: &row.name, - version: row.version, - action: "update", - tags: &row.tags, - metadata: &history_metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry history before web update"); - } - - let entry_type = params.entry_type.trim(); - - let res = sqlx::query( - "UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \ - version = version + 1, updated_at = NOW() \ - WHERE id = $7 AND version = $8", - ) - .bind(params.folder) - .bind(entry_type) - .bind(params.name) - .bind(params.notes) - .bind(params.tags) - .bind(params.metadata) - .bind(row.id) - .bind(row.version) - .execute(&mut *tx) - .await - .map_err(|e| { - if let sqlx::Error::Database(ref d) = e - && d.code().as_deref() == Some("23505") - { - return AppError::ConflictEntryName { - folder: params.folder.to_string(), - name: params.name.to_string(), - }; - } - AppError::Internal(e.into()) - })?; - - if res.rows_affected() == 0 { - tx.rollback().await?; - return Err(AppError::ConcurrentModification.into()); - } - - crate::audit::log_tx( - &mut tx, - Some(user_id), - "update", - params.folder, - entry_type, - params.name, - serde_json::json!({ - "source": "web", - "entry_id": entry_id, - "fields": ["folder", "type", "name", "notes", "tags", "metadata"], - }), - ) - .await; - - tx.commit().await?; - Ok(()) -} diff --git a/crates/secrets-core/src/service/user.rs b/crates/secrets-core/src/service/user.rs deleted file mode 100644 index acf6a56..0000000 --- a/crates/secrets-core/src/service/user.rs +++ /dev/null @@ -1,349 +0,0 @@ -use anyhow::Result; -use serde_json::Value; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::models::{OauthAccount, User}; - -pub struct OAuthProfile { - pub provider: String, - pub provider_id: String, - pub email: Option, - pub name: Option, - pub avatar_url: Option, -} - -/// Find or create a user from an OAuth profile. -/// Returns (user, is_new) where is_new indicates first-time registration. -pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> { - // Use a transaction with FOR UPDATE to prevent TOCTOU race conditions - let mut tx = pool.begin().await?; - - // Check if this OAuth account already exists (with row lock) - let existing: Option = sqlx::query_as( - "SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \ - FROM oauth_accounts WHERE provider = $1 AND provider_id = $2 FOR UPDATE", - ) - .bind(&profile.provider) - .bind(&profile.provider_id) - .fetch_optional(&mut *tx) - .await?; - - if let Some(oa) = existing { - let user: User = sqlx::query_as( - "SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \ - FROM users WHERE id = $1", - ) - .bind(oa.user_id) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - return Ok((user, false)); - } - - // New user — create records (no key yet; user sets passphrase on dashboard) - let display_name = profile - .name - .clone() - .unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string())); - - let user: User = sqlx::query_as( - "INSERT INTO users (email, name, avatar_url) \ - VALUES ($1, $2, $3) \ - RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at", - ) - .bind(&profile.email) - .bind(&display_name) - .bind(&profile.avatar_url) - .fetch_one(&mut *tx) - .await?; - - sqlx::query( - "INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \ - VALUES ($1, $2, $3, $4, $5, $6)", - ) - .bind(user.id) - .bind(&profile.provider) - .bind(&profile.provider_id) - .bind(&profile.email) - .bind(&profile.name) - .bind(&profile.avatar_url) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok((user, true)) -} - -/// Re-encrypt all of a user's secrets from `old_key` to `new_key` and update the key metadata. -/// -/// Runs entirely inside a single database transaction: if any secret fails to re-encrypt -/// the whole operation is rolled back, leaving the database unchanged. -pub async fn change_user_key( - pool: &PgPool, - user_id: Uuid, - old_key: &[u8; 32], - new_key: &[u8; 32], - new_salt: &[u8], - new_key_check: &[u8], - new_key_params: &Value, -) -> Result<()> { - let mut tx = pool.begin().await?; - - let secrets: Vec<(uuid::Uuid, Vec)> = - sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 FOR UPDATE") - .bind(user_id) - .fetch_all(&mut *tx) - .await?; - - for (id, encrypted) in &secrets { - let plaintext = crate::crypto::decrypt(old_key, encrypted)?; - let new_encrypted = crate::crypto::encrypt(new_key, &plaintext)?; - sqlx::query("UPDATE secrets SET encrypted = $1, updated_at = NOW() WHERE id = $2") - .bind(&new_encrypted) - .bind(id) - .execute(&mut *tx) - .await?; - } - - sqlx::query( - "UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, \ - key_version = key_version + 1, updated_at = NOW() \ - WHERE id = $4", - ) - .bind(new_salt) - .bind(new_key_check) - .bind(new_key_params) - .bind(user_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(()) -} - -/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup. -pub async fn update_user_key_setup( - pool: &PgPool, - user_id: Uuid, - key_salt: &[u8], - key_check: &[u8], - key_params: &Value, -) -> Result<()> { - sqlx::query( - "UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \ - WHERE id = $4", - ) - .bind(key_salt) - .bind(key_check) - .bind(key_params) - .bind(user_id) - .execute(pool) - .await?; - Ok(()) -} - -/// Fetch a user by ID. -pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result> { - let user = sqlx::query_as( - "SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \ - FROM users WHERE id = $1", - ) - .bind(user_id) - .fetch_optional(pool) - .await?; - Ok(user) -} - -/// List all OAuth accounts linked to a user. -pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result> { - let accounts = sqlx::query_as( - "SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \ - FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at", - ) - .bind(user_id) - .fetch_all(pool) - .await?; - Ok(accounts) -} - -/// Bind an additional OAuth account to an existing user. -pub async fn bind_oauth_account( - pool: &PgPool, - user_id: Uuid, - profile: OAuthProfile, -) -> Result { - // Use a transaction with FOR UPDATE to prevent TOCTOU race conditions - let mut tx = pool.begin().await?; - - // Check if this provider_id is already linked to someone else (with row lock) - let conflict: Option<(Uuid,)> = sqlx::query_as( - "SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2 FOR UPDATE", - ) - .bind(&profile.provider) - .bind(&profile.provider_id) - .fetch_optional(&mut *tx) - .await?; - - if let Some((existing_user_id,)) = conflict { - if existing_user_id != user_id { - anyhow::bail!( - "This {} account is already linked to a different user", - profile.provider - ); - } - anyhow::bail!( - "This {} account is already linked to your account", - profile.provider - ); - } - - let existing_provider_for_user: Option<(String,)> = sqlx::query_as( - "SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2 FOR UPDATE", - ) - .bind(user_id) - .bind(&profile.provider) - .fetch_optional(&mut *tx) - .await?; - - if existing_provider_for_user.is_some() { - anyhow::bail!( - "You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.", - profile.provider, - profile.provider - ); - } - - let account: OauthAccount = sqlx::query_as( - "INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \ - VALUES ($1, $2, $3, $4, $5, $6) \ - RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at", - ) - .bind(user_id) - .bind(&profile.provider) - .bind(&profile.provider_id) - .bind(&profile.email) - .bind(&profile.name) - .bind(&profile.avatar_url) - .fetch_one(&mut *tx) - .await?; - - tx.commit().await?; - Ok(account) -} - -/// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider. -pub async fn unbind_oauth_account( - pool: &PgPool, - user_id: Uuid, - provider: &str, - current_login_provider: Option<&str>, -) -> Result<()> { - if current_login_provider == Some(provider) { - anyhow::bail!( - "Cannot unlink the {} account you are currently using to sign in", - provider - ); - } - - let mut tx = pool.begin().await?; - - let locked_accounts: Vec<(String,)> = - sqlx::query_as("SELECT provider FROM oauth_accounts WHERE user_id = $1 FOR UPDATE") - .bind(user_id) - .fetch_all(&mut *tx) - .await?; - let count = locked_accounts.len(); - - if count <= 1 { - anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first."); - } - - sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2") - .bind(user_id) - .bind(provider) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn maybe_test_pool() -> Option { - let database_url = match std::env::var("SECRETS_DATABASE_URL") { - Ok(v) => v, - Err(_) => { - eprintln!("skip user service tests: SECRETS_DATABASE_URL not set"); - return None; - } - }; - let pool = match sqlx::PgPool::connect(&database_url).await { - Ok(pool) => pool, - Err(e) => { - eprintln!("skip user service tests: cannot connect to database: {e}"); - return None; - } - }; - if let Err(e) = crate::db::migrate(&pool).await { - eprintln!("skip user service tests: migrate failed: {e}"); - return None; - } - Some(pool) - } - - async fn cleanup_user_rows(pool: &PgPool, user_id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1") - .bind(user_id) - .execute(pool) - .await?; - sqlx::query("DELETE FROM users WHERE id = $1") - .bind(user_id) - .execute(pool) - .await?; - Ok(()) - } - - #[tokio::test] - async fn unbind_oauth_account_removes_only_requested_provider() -> Result<()> { - let Some(pool) = maybe_test_pool().await else { - return Ok(()); - }; - let user_id = Uuid::from_u128(rand::random()); - - cleanup_user_rows(&pool, user_id).await?; - - sqlx::query("INSERT INTO users (id, name) VALUES ($1, '')") - .bind(user_id) - .execute(&pool) - .await?; - sqlx::query( - "INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \ - VALUES ($1, 'google', $2, NULL, NULL, NULL), \ - ($1, 'github', $3, NULL, NULL, NULL)", - ) - .bind(user_id) - .bind(format!("google-{user_id}")) - .bind(format!("github-{user_id}")) - .execute(&pool) - .await?; - - unbind_oauth_account(&pool, user_id, "github", Some("google")).await?; - - let remaining: Vec<(String,)> = sqlx::query_as( - "SELECT provider FROM oauth_accounts WHERE user_id = $1 ORDER BY provider", - ) - .bind(user_id) - .fetch_all(&pool) - .await?; - assert_eq!(remaining, vec![("google".to_string(),)]); - - cleanup_user_rows(&pool, user_id).await?; - Ok(()) - } -} diff --git a/crates/secrets-core/src/service/util.rs b/crates/secrets-core/src/service/util.rs deleted file mode 100644 index cda3933..0000000 --- a/crates/secrets-core/src/service/util.rs +++ /dev/null @@ -1,27 +0,0 @@ -use uuid::Uuid; - -/// Returns a WHERE condition fragment for user scope and advances `idx` if `user_id` is Some. -/// -/// - `Some(uid)` → `"user_id = $N"` with idx incremented. -/// - `None` → `"user_id IS NULL"` with idx unchanged. -/// -/// # Usage -/// -/// ```rust,ignore -/// let mut idx = 1i32; -/// let user_cond = user_scope_condition(user_id, &mut idx); -/// // idx is now 2 if user_id is Some, still 1 if None -/// let sql = format!("SELECT ... FROM entries WHERE {user_cond} AND name = ${idx}"); -/// let mut q = sqlx::query_as::<_, Row>(&sql); -/// if let Some(uid) = user_id { q = q.bind(uid); } -/// q = q.bind(name); -/// ``` -pub fn user_scope_condition(user_id: Option, idx: &mut i32) -> String { - if user_id.is_some() { - let s = format!("user_id = ${}", *idx); - *idx += 1; - s - } else { - "user_id IS NULL".to_string() - } -} diff --git a/crates/secrets-core/src/taxonomy.rs b/crates/secrets-core/src/taxonomy.rs deleted file mode 100644 index fbf48be..0000000 --- a/crates/secrets-core/src/taxonomy.rs +++ /dev/null @@ -1,4 +0,0 @@ -/// Canonical secret type options for UI dropdowns. -pub const SECRET_TYPE_OPTIONS: &[&str] = &[ - "text", "password", "token", "api-key", "ssh-key", "url", "phone", "id-card", -]; diff --git a/crates/secrets-mcp-local/Cargo.toml b/crates/secrets-mcp-local/Cargo.toml deleted file mode 100644 index 37eff9e..0000000 --- a/crates/secrets-mcp-local/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "secrets-mcp-local" -version = "0.1.0" -edition.workspace = true -description = "Local MCP gateway for onboarding, unlock caching, and delegated target execution" -license = "MIT OR Apache-2.0" - -[[bin]] -name = "secrets-mcp-local" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -axum = "0.8" -dotenvy.workspace = true -reqwest = { workspace = true, features = ["stream"] } -secrets-core = { path = "../secrets-core" } -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -tracing.workspace = true -tracing-subscriber = { workspace = true, features = ["env-filter"] } -url = "2" -uuid.workspace = true diff --git a/crates/secrets-mcp-local/src/bind.rs b/crates/secrets-mcp-local/src/bind.rs deleted file mode 100644 index a5bd132..0000000 --- a/crates/secrets-mcp-local/src/bind.rs +++ /dev/null @@ -1,212 +0,0 @@ -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use serde::Deserialize; -use serde_json::{Value, json}; - -use crate::cache::{BoundState, PendingBindState}; -use crate::server::AppState; - -#[derive(Deserialize)] -pub struct BindExchangeBody { - bind_id: Option, - device_code: Option, -} - -fn bind_exchange_error_message(value: &Value) -> String { - value - .get("error") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned) - .or_else(|| { - value - .get("message") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned) - }) - .unwrap_or_else(|| value.to_string()) -} - -pub async fn refresh_bound_state(state: &AppState) { - let api_key = { - let guard = state.cache.read().await; - guard.bound.as_ref().map(|bound| bound.api_key.clone()) - }; - let Some(api_key) = api_key else { - return; - }; - if let Ok(refreshed) = state.remote.bind_refresh(&api_key).await { - let mut guard = state.cache.write().await; - if matches!(refreshed.status, 401 | 404) { - guard.clear_bound_and_unlock(); - return; - } - if let Some(refreshed) = refreshed.body { - let clear_unlock = if let Some(bound) = guard.bound.as_mut() { - let changed = bound.key_version != refreshed.key_version; - bound.key_version = refreshed.key_version; - bound.key_salt_hex = refreshed.key_salt_hex.clone(); - bound.key_check_hex = refreshed.key_check_hex.clone(); - bound.key_params = refreshed.key_params.clone(); - changed - } else { - false - }; - if clear_unlock { - guard.clear_unlock_and_exec(); - } - } - } -} - -pub async fn start_bind(state: &AppState) -> Result { - let res = state - .remote - .bind_start() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("bind/start failed: {e}")))?; - let started_at = std::time::Instant::now(); - let expires_at = started_at + std::time::Duration::from_secs(res.expires_in_secs); - let mut guard = state.cache.write().await; - guard.clear_bound_and_unlock(); - guard.pending_bind = Some(PendingBindState { - bind_id: res.bind_id.clone(), - device_code: res.device_code.clone(), - approve_url: res.approve_url.clone(), - expires_at, - started_at, - }); - Ok(json!({ - "ok": true, - "bind_id": res.bind_id, - "device_code": res.device_code, - "approve_url": res.approve_url, - "expires_in_secs": res.expires_in_secs, - "onboarding_url": format!("http://{}/", state.config.bind), - "next_action": "在浏览器打开 approve_url 完成授权,然后继续轮询 local_bind_exchange", - })) -} - -pub async fn exchange_bind( - state: &AppState, - bind_id: Option, - device_code: Option, -) -> Result<(StatusCode, serde_json::Value), (StatusCode, String)> { - let (bind_id, device_code) = if let (Some(bind_id), Some(device_code)) = (bind_id, device_code) - { - (bind_id, device_code) - } else { - let guard = state.cache.read().await; - let pending = guard.pending_bind.as_ref().ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - "missing bind session; call /local/bind/start first".to_string(), - ) - })?; - (pending.bind_id.clone(), pending.device_code.clone()) - }; - - let result = state - .remote - .bind_exchange(&bind_id, &device_code) - .await - .map_err(|e| { - ( - StatusCode::BAD_GATEWAY, - format!("bind/exchange failed: {e}"), - ) - })?; - let status = result.status; - let payload = result.body; - - if status == 202 || payload.get("status").and_then(|v| v.as_str()) == Some("pending") { - let approve_url = { - let guard = state.cache.read().await; - guard - .pending_bind - .as_ref() - .filter(|pending| pending.bind_id == bind_id && pending.device_code == device_code) - .map(|pending| pending.approve_url.clone()) - }; - return Ok(( - StatusCode::ACCEPTED, - json!({ - "ok": false, - "status": "pending", - "bind_id": bind_id, - "device_code": device_code, - "approve_url": approve_url, - "next_action": "继续等待远端授权完成,或重新打开 approve_url", - }), - )); - } - if !(200..300).contains(&status) { - return Err(( - StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY), - bind_exchange_error_message(&payload), - )); - } - let payload: crate::remote::BindExchangeResponse = - serde_json::from_value(payload).map_err(|e| { - ( - StatusCode::BAD_GATEWAY, - format!("invalid bind/exchange response: {e}"), - ) - })?; - let api_key = payload.api_key.ok_or_else(|| { - ( - StatusCode::BAD_GATEWAY, - "bind/exchange missing api_key".to_string(), - ) - })?; - let user_id = payload.user_id.ok_or_else(|| { - ( - StatusCode::BAD_GATEWAY, - "bind/exchange missing user_id".to_string(), - ) - })?; - let mut guard = state.cache.write().await; - guard.clear_pending_bind(); - guard.bound = Some(BoundState { - user_id, - api_key, - key_salt_hex: payload.key_salt_hex, - key_check_hex: payload.key_check_hex, - key_params: payload.key_params, - key_version: payload.key_version.unwrap_or(0), - bound_at: std::time::Instant::now(), - }); - guard.clear_unlock_and_exec(); - - Ok(( - StatusCode::OK, - json!({ - "ok": true, - "status": "bound", - "unlock_url": format!("http://{}/unlock", state.config.bind), - "onboarding_url": format!("http://{}/", state.config.bind), - "next_action": "打开本地 unlock 页面完成 passphrase 解锁", - }), - )) -} - -pub async fn bind_start( - State(state): State, -) -> Result { - let payload = start_bind(&state).await?; - Ok((StatusCode::OK, axum::Json(payload))) -} - -pub async fn bind_exchange( - State(state): State, - axum::Json(input): axum::Json, -) -> Result { - let (status, payload) = exchange_bind(&state, input.bind_id, input.device_code).await?; - Ok((status, axum::Json(payload))) -} - -pub async fn unbind(State(state): State) -> impl IntoResponse { - let mut guard = state.cache.write().await; - guard.clear_bound_and_unlock(); - (StatusCode::OK, axum::Json(json!({ "ok": true }))) -} diff --git a/crates/secrets-mcp-local/src/cache.rs b/crates/secrets-mcp-local/src/cache.rs deleted file mode 100644 index 23effe2..0000000 --- a/crates/secrets-mcp-local/src/cache.rs +++ /dev/null @@ -1,234 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::sync::RwLock; -use uuid::Uuid; - -use crate::target::ExecutionTarget; - -#[derive(Clone)] -pub struct BoundState { - pub user_id: Uuid, - pub api_key: String, - pub key_salt_hex: Option, - pub key_check_hex: Option, - pub key_params: Option, - pub key_version: i64, - pub bound_at: Instant, -} - -#[derive(Clone)] -pub struct UnlockState { - pub encryption_key_hex: String, - pub expires_at: Instant, - pub last_used_at: Instant, -} - -#[derive(Clone)] -pub struct ExecContext { - pub target: ExecutionTarget, - pub expires_at: Instant, - pub last_used_at: Instant, -} - -#[derive(Clone)] -pub struct PendingBindState { - pub bind_id: String, - pub device_code: String, - pub approve_url: String, - pub expires_at: Instant, - pub started_at: Instant, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GatewayPhase { - Bootstrap, - PendingUnlock, - Ready, -} - -#[derive(Default)] -pub struct GatewayCache { - pub pending_bind: Option, - pub bound: Option, - pub unlock: Option, - pub exec_contexts: HashMap, -} - -impl GatewayCache { - pub fn clear_bound_and_unlock(&mut self) { - self.pending_bind = None; - self.bound = None; - self.unlock = None; - self.exec_contexts.clear(); - } - - pub fn clear_pending_bind(&mut self) { - self.pending_bind = None; - } - - pub fn clear_unlock_and_exec(&mut self) { - self.unlock = None; - self.exec_contexts.clear(); - } - - pub fn phase(&self, now: Instant) -> GatewayPhase { - if self.bound.is_none() { - return GatewayPhase::Bootstrap; - } - if self - .unlock - .as_ref() - .is_some_and(|unlock| unlock.expires_at > now && !unlock.encryption_key_hex.is_empty()) - { - GatewayPhase::Ready - } else { - GatewayPhase::PendingUnlock - } - } -} - -pub type SharedCache = Arc>; - -pub fn new_cache() -> SharedCache { - Arc::new(RwLock::new(GatewayCache::default())) -} - -fn cleanup_expired(cache: &mut GatewayCache, now: Instant) { - if cache - .pending_bind - .as_ref() - .is_some_and(|bind| bind.expires_at <= now) - { - cache.pending_bind = None; - } - if let Some(unlock) = cache.unlock.as_ref() - && unlock.expires_at <= now - { - cache.clear_unlock_and_exec(); - } - cache.exec_contexts.retain(|_, ctx| ctx.expires_at > now); - if cache.unlock.is_none() { - cache.exec_contexts.clear(); - } -} - -pub fn spawn_cleanup_task(cache: SharedCache) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(30)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - interval.tick().await; - let now = Instant::now(); - let mut guard = cache.write().await; - cleanup_expired(&mut guard, now); - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - - use crate::target::ResolvedTarget; - - #[tokio::test] - async fn cleanup_task_clears_expired_unlock() { - let mut cache = GatewayCache { - pending_bind: None, - bound: None, - unlock: Some(UnlockState { - encryption_key_hex: "11".repeat(32), - expires_at: Instant::now() - Duration::from_secs(1), - last_used_at: Instant::now(), - }), - exec_contexts: HashMap::new(), - }; - cleanup_expired(&mut cache, Instant::now()); - assert!(cache.unlock.is_none()); - assert!(cache.exec_contexts.is_empty()); - } - - #[test] - fn clear_unlock_and_exec_drops_entry_contexts() { - let mut cache = GatewayCache { - pending_bind: None, - bound: None, - unlock: Some(UnlockState { - encryption_key_hex: "11".repeat(32), - expires_at: Instant::now() + Duration::from_secs(30), - last_used_at: Instant::now(), - }), - exec_contexts: HashMap::from([( - "entry-1".to_string(), - ExecContext { - target: ExecutionTarget { - resolved: ResolvedTarget { - id: "entry-1".to_string(), - folder: "refining".to_string(), - name: "api".to_string(), - entry_type: Some("service".to_string()), - }, - env: BTreeMap::from([( - "TARGET_API_KEY".to_string(), - "sk_test".to_string(), - )]), - }, - expires_at: Instant::now() + Duration::from_secs(30), - last_used_at: Instant::now(), - }, - )]), - }; - cache.clear_unlock_and_exec(); - assert!(cache.unlock.is_none()); - assert!(cache.exec_contexts.is_empty()); - } - - #[test] - fn cleanup_drops_expired_pending_bind() { - let mut cache = GatewayCache { - pending_bind: Some(PendingBindState { - bind_id: "bind-1".to_string(), - device_code: "device-1".to_string(), - approve_url: "http://example.com/approve".to_string(), - expires_at: Instant::now() - Duration::from_secs(1), - started_at: Instant::now() - Duration::from_secs(30), - }), - bound: None, - unlock: None, - exec_contexts: HashMap::new(), - }; - cleanup_expired(&mut cache, Instant::now()); - assert!(cache.pending_bind.is_none()); - } - - #[test] - fn phase_transitions_match_bound_and_unlock() { - let now = Instant::now(); - let mut cache = GatewayCache::default(); - assert_eq!(cache.phase(now), GatewayPhase::Bootstrap); - - cache.bound = Some(BoundState { - user_id: Uuid::nil(), - api_key: "api-key".to_string(), - key_salt_hex: None, - key_check_hex: None, - key_params: None, - key_version: 0, - bound_at: now, - }); - assert_eq!(cache.phase(now), GatewayPhase::PendingUnlock); - - cache.unlock = Some(UnlockState { - encryption_key_hex: "11".repeat(32), - expires_at: now + Duration::from_secs(60), - last_used_at: now, - }); - assert_eq!(cache.phase(now), GatewayPhase::Ready); - } -} diff --git a/crates/secrets-mcp-local/src/config.rs b/crates/secrets-mcp-local/src/config.rs deleted file mode 100644 index 85643b5..0000000 --- a/crates/secrets-mcp-local/src/config.rs +++ /dev/null @@ -1,46 +0,0 @@ -use anyhow::{Context, Result}; -use std::net::SocketAddr; -use std::time::Duration; -use url::Url; - -const DEFAULT_BIND: &str = "127.0.0.1:9316"; -const DEFAULT_UNLOCK_TTL_SECS: u64 = 3600; -const DEFAULT_EXEC_CONTEXT_TTL_SECS: u64 = 3600; - -#[derive(Clone)] -pub struct LocalConfig { - pub bind: SocketAddr, - pub remote_base_url: Url, - pub default_unlock_ttl: Duration, - pub default_exec_context_ttl: Duration, -} - -fn load_env(name: &str) -> Option { - std::env::var(name).ok().filter(|s| !s.is_empty()) -} - -pub fn load_config() -> Result { - let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string()); - let bind: SocketAddr = bind - .parse() - .with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?; - - let remote_base_url: Url = load_env("SECRETS_REMOTE_BASE_URL") - .context("SECRETS_REMOTE_BASE_URL is required")? - .parse() - .context("invalid SECRETS_REMOTE_BASE_URL")?; - - let unlock_ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS") - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_UNLOCK_TTL_SECS); - let exec_context_ttl_secs: u64 = load_env("SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS") - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_EXEC_CONTEXT_TTL_SECS); - - Ok(LocalConfig { - bind, - remote_base_url, - default_unlock_ttl: Duration::from_secs(unlock_ttl_secs.clamp(60, 86400 * 7)), - default_exec_context_ttl: Duration::from_secs(exec_context_ttl_secs.clamp(60, 86400 * 7)), - }) -} diff --git a/crates/secrets-mcp-local/src/main.rs b/crates/secrets-mcp-local/src/main.rs deleted file mode 100644 index 2dc375e..0000000 --- a/crates/secrets-mcp-local/src/main.rs +++ /dev/null @@ -1,55 +0,0 @@ -mod bind; -mod cache; -mod config; -mod exec; -mod mcp; -mod remote; -mod server; -mod target; -mod unlock; - -use anyhow::{Context, Result}; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() -> Result<()> { - let _ = dotenvy::dotenv(); - - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "secrets_mcp_local=info,tower_http=info".into()), - ) - .init(); - - let config = config::load_config()?; - let remote = std::sync::Arc::new(remote::RemoteClient::new(config.remote_base_url.clone())?); - let cache = cache::new_cache(); - let cleanup = cache::spawn_cleanup_task(cache.clone()); - - let app_state = server::AppState { - config: config.clone(), - cache, - remote, - }; - let app = server::router(app_state); - - tracing::info!( - bind = %config.bind, - remote = %config.remote_base_url, - "secrets-mcp-local service started" - ); - - let listener = tokio::net::TcpListener::bind(config.bind) - .await - .with_context(|| format!("failed to bind {}", config.bind))?; - - let result = axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await - .context("server error"); - cleanup.abort(); - result -} diff --git a/crates/secrets-mcp-local/src/mcp.rs b/crates/secrets-mcp-local/src/mcp.rs deleted file mode 100644 index 499a2e2..0000000 --- a/crates/secrets-mcp-local/src/mcp.rs +++ /dev/null @@ -1,828 +0,0 @@ -use std::convert::Infallible; -use std::time::Instant; - -use axum::body::Body; -use axum::extract::State; -use axum::http::{StatusCode, header}; -use axum::response::Response; -use serde::Deserialize; -use serde_json::{Value, json}; - -use crate::bind::{exchange_bind, start_bind}; -use crate::cache::{ExecContext, GatewayPhase}; -use crate::exec::{TargetExecInput, execute_command}; -use crate::server::AppState; -use crate::target::{TargetSnapshot, build_execution_target}; -use crate::unlock::status_payload; - -const LOCAL_EXEC_TOOL: &str = "target_exec"; - -#[derive(Deserialize, Default)] -struct BindExchangeArgs { - bind_id: Option, - device_code: Option, -} - -fn json_response(status: StatusCode, value: Value) -> Response { - Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "application/json; charset=utf-8") - .body(Body::from(value.to_string())) - .unwrap() -} - -fn jsonrpc_result_response(id: Value, result: Value) -> Response { - json_response( - StatusCode::OK, - json!({ - "jsonrpc": "2.0", - "id": id, - "result": result, - }), - ) -} - -fn tool_success_response(id: Value, value: Value) -> Response { - let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); - jsonrpc_result_response( - id, - json!({ - "content": [ - { - "type": "text", - "text": pretty, - } - ], - "isError": false - }), - ) -} - -fn tool_error_response(id: Value, message: impl Into) -> Response { - jsonrpc_result_response( - id, - json!({ - "content": [ - { - "type": "text", - "text": message.into(), - } - ], - "isError": true - }), - ) -} - -fn empty_notification_response() -> Response { - Response::builder() - .status(StatusCode::ACCEPTED) - .body(Body::empty()) - .unwrap() -} - -fn method_not_found(id: Value, method: &str) -> Response { - json_response( - StatusCode::OK, - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32601, - "message": format!("method `{method}` not supported by secrets-mcp-local"), - } - }), - ) -} - -fn invalid_request_response(message: impl Into) -> Response { - json_response( - StatusCode::BAD_REQUEST, - json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32600, - "message": message.into(), - } - }), - ) -} - -fn status_tool_definitions() -> Vec { - vec![ - json!({ - "name": "local_status", - "description": "Read the local gateway readiness state, onboarding URL, unlock URL, and any pending approval session.", - "inputSchema": { "type": "object", "properties": {} }, - "annotations": { "title": "Local MCP Status" } - }), - json!({ - "name": "local_unlock_status", - "description": "Return whether the local gateway is waiting for passphrase unlock or already ready.", - "inputSchema": { "type": "object", "properties": {} }, - "annotations": { "title": "Local Unlock Status" } - }), - json!({ - "name": "local_onboarding_info", - "description": "Return the local onboarding page URL, MCP URL, and current next-step guidance for the user.", - "inputSchema": { "type": "object", "properties": {} }, - "annotations": { "title": "Local Onboarding Info" } - }), - ] -} - -fn bind_tool_definitions() -> Vec { - vec![ - json!({ - "name": "local_bind_start", - "description": "Start a new remote authorization session and return the approve_url that the user should open in a browser.", - "inputSchema": { "type": "object", "properties": {} }, - "annotations": { "title": "Start Local MCP Binding" } - }), - json!({ - "name": "local_bind_exchange", - "description": "Poll the current bind session. When the user has approved in the browser, this moves the gateway into pendingUnlock and returns the local unlock URL.", - "inputSchema": { - "type": "object", - "properties": { - "bind_id": { "type": ["string", "null"] }, - "device_code": { "type": ["string", "null"] } - } - }, - "annotations": { "title": "Poll Binding State" } - }), - ] -} - -fn ready_tool_definitions() -> Vec { - vec![ - json!({ - "name": "secrets_find", - "description": "Find entries in the secrets store and return target snapshots suitable for target_exec.", - "inputSchema": { - "type": "object", - "properties": { - "query": { "type": ["string", "null"] }, - "metadata_query": { "type": ["string", "null"] }, - "folder": { "type": ["string", "null"] }, - "type": { "type": ["string", "null"] }, - "name": { "type": ["string", "null"] }, - "name_query": { "type": ["string", "null"] }, - "tags": { "type": ["array", "null"], "items": { "type": "string" } }, - "limit": { "type": ["integer", "null"] }, - "offset": { "type": ["integer", "null"] } - } - }, - "annotations": { "title": "Find Secrets" } - }), - json!({ - "name": "secrets_search", - "description": "Search entries with optional summary mode. Returns metadata and secret field names, not secret values.", - "inputSchema": { - "type": "object", - "properties": { - "query": { "type": ["string", "null"] }, - "metadata_query": { "type": ["string", "null"] }, - "folder": { "type": ["string", "null"] }, - "type": { "type": ["string", "null"] }, - "name": { "type": ["string", "null"] }, - "name_query": { "type": ["string", "null"] }, - "tags": { "type": ["array", "null"], "items": { "type": "string" } }, - "summary": { "type": ["boolean", "null"] }, - "sort": { "type": ["string", "null"] }, - "limit": { "type": ["integer", "null"] }, - "offset": { "type": ["integer", "null"] } - } - }, - "annotations": { "title": "Search Secrets" } - }), - json!({ - "name": "secrets_history", - "description": "View change history for an entry by id or by name/folder.", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": ["string", "null"] }, - "name": { "type": ["string", "null"] }, - "folder": { "type": ["string", "null"] }, - "limit": { "type": ["integer", "null"] } - } - }, - "annotations": { "title": "View Secret History" } - }), - json!({ - "name": "secrets_overview", - "description": "Get counts of entries per folder and per type.", - "inputSchema": { "type": "object", "properties": {} }, - "annotations": { "title": "Secrets Overview" } - }), - json!({ - "name": "secrets_delete", - "description": "Preview deletions only. dry_run must be true.", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": ["string", "null"] }, - "name": { "type": ["string", "null"] }, - "folder": { "type": ["string", "null"] }, - "type": { "type": ["string", "null"] }, - "dry_run": { "type": ["boolean", "null"] } - } - }, - "annotations": { "title": "Delete Secret Entry Preview", "destructiveHint": true } - }), - json!({ - "name": LOCAL_EXEC_TOOL, - "description": "Execute a standard local command against a resolved secrets target. The local gateway injects target metadata and secret values as environment variables without exposing raw secret values to the AI.", - "inputSchema": { - "type": "object", - "properties": { - "target_ref": { - "type": ["string", "null"], - "description": "Target entry id from secrets_find/secrets_search. Required on first use; later calls may reuse the cached execution context for the same entry id." - }, - "target": { - "type": ["object", "null"], - "description": "Optional target snapshot copied from secrets_find/secrets_search. Required on first use when the local gateway has not cached this entry id yet." - }, - "command": { - "type": "string", - "description": "Standard shell command to execute locally, such as ssh/curl/docker/http." - }, - "timeout_secs": { - "type": ["integer", "null"], - "description": "Execution timeout in seconds." - }, - "working_dir": { - "type": ["string", "null"], - "description": "Optional working directory for the command." - }, - "env_overrides": { - "type": ["object", "null"], - "description": "Optional extra environment variables. Reserved TARGET_* names cannot be overridden." - } - }, - "required": ["command"] - }, - "annotations": { "title": "Execute Against Target" } - }), - ] -} - -fn tools_for_phase(phase: GatewayPhase) -> Vec { - let mut tools = status_tool_definitions(); - if phase != GatewayPhase::Ready { - tools.extend(bind_tool_definitions()); - } - if phase == GatewayPhase::Ready { - tools.extend(ready_tool_definitions()); - } - tools -} - -async fn current_phase_and_status(state: &AppState) -> (GatewayPhase, Value) { - let payload = status_payload(state).await; - let phase = payload - .get("state") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()) - .unwrap_or(GatewayPhase::Bootstrap); - (phase, payload) -} - -fn instructions_for_phase(phase: GatewayPhase) -> &'static str { - match phase { - GatewayPhase::Bootstrap => { - "Use local_status and local_bind_start first. The user must open the approve_url in a browser before the local gateway can continue." - } - GatewayPhase::PendingUnlock => { - "Remote authorization is complete. Ask the user to open the local unlock page and finish passphrase unlock before calling business tools." - } - GatewayPhase::Ready => { - "The local gateway is ready. Use secrets_find/secrets_search for discovery and target_exec for delegated command execution against decrypted targets." - } - } -} - -fn initialize_response(id: Value, phase: GatewayPhase) -> Response { - let session_id = format!( - "local-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0) - ); - let response = json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "protocolVersion": "2025-06-18", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "secrets-mcp-local", - "version": env!("CARGO_PKG_VERSION"), - "title": "Secrets MCP Local" - }, - "instructions": instructions_for_phase(phase), - } - }); - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json; charset=utf-8") - .header("mcp-session-id", session_id) - .body(Body::from(response.to_string())) - .unwrap() -} - -async fn resolve_target_context( - state: &AppState, - api_key: &str, - unlock_key: &str, - unlock_expires_at: Instant, - input: &TargetExecInput, -) -> anyhow::Result { - let target_ref = input - .target_ref - .clone() - .or_else(|| input.target.as_ref().map(|t| t.id.clone())) - .ok_or_else(|| anyhow::anyhow!("target_ref is required"))?; - - { - let mut guard = state.cache.write().await; - if let Some(ctx) = guard.exec_contexts.get_mut(&target_ref) - && ctx.expires_at > Instant::now() - { - ctx.last_used_at = Instant::now(); - return Ok(ctx.target.clone()); - } - } - - let snapshot: TargetSnapshot = input.target.clone().ok_or_else(|| { - anyhow::anyhow!( - "target details required on first use for entry `{target_ref}`; pass the matching secrets_find/search result as `target`" - ) - })?; - if snapshot.id != target_ref { - return Err(anyhow::anyhow!( - "target_ref `{target_ref}` does not match target.id `{}`", - snapshot.id - )); - } - - let secrets = state - .remote - .get_entry_secrets_by_id(api_key, unlock_key, &target_ref) - .await?; - let target = build_execution_target(&snapshot, &secrets)?; - let expires_at = std::cmp::min( - Instant::now() + state.config.default_exec_context_ttl, - unlock_expires_at, - ); - let mut guard = state.cache.write().await; - guard.exec_contexts.insert( - target_ref, - ExecContext { - target: target.clone(), - expires_at, - last_used_at: Instant::now(), - }, - ); - Ok(target) -} - -async fn handle_target_exec(state: &AppState, id: Value, args: Option) -> Response { - let input: TargetExecInput = match args { - Some(value) => match serde_json::from_value(value) { - Ok(input) => input, - Err(err) => { - return tool_error_response(id, format!("invalid `{LOCAL_EXEC_TOOL}` args: {err}")); - } - }, - None => { - return tool_error_response(id, format!("`{LOCAL_EXEC_TOOL}` arguments are required")); - } - }; - if input.command.trim().is_empty() { - return tool_error_response(id, "command is required"); - } - - let api_key = { - let guard = state.cache.read().await; - match guard.bound.as_ref() { - Some(bound) => bound.api_key.clone(), - None => { - return tool_error_response( - id, - "local MCP is not bound; call local_bind_start first", - ); - } - } - }; - let (unlock_key, unlock_expires_at) = { - let mut guard = state.cache.write().await; - match guard.unlock.as_mut() { - Some(unlock) if unlock.expires_at > Instant::now() => { - unlock.last_used_at = Instant::now(); - (unlock.encryption_key_hex.clone(), unlock.expires_at) - } - _ => { - guard.clear_unlock_and_exec(); - return tool_error_response( - id, - "local MCP is not unlocked; ask the user to open the local unlock page first", - ); - } - } - }; - let target = - match resolve_target_context(state, &api_key, &unlock_key, unlock_expires_at, &input).await - { - Ok(target) => target, - Err(err) => return tool_error_response(id, format!("failed resolving target: {err}")), - }; - let timeout_secs = input.timeout_secs.unwrap_or(120).clamp(1, 3600); - let result = match execute_command(&input, &target, timeout_secs).await { - Ok(result) => result, - Err(err) => return tool_error_response(id, format!("execution failed: {err}")), - }; - tool_success_response( - id, - serde_json::to_value(result).unwrap_or_else(|_| json!({})), - ) -} - -async fn handle_bootstrap_tool( - state: &AppState, - tool_name: &str, - id: Value, - args: Option, -) -> Response { - match tool_name { - "local_status" | "local_unlock_status" | "local_onboarding_info" => { - tool_success_response(id, status_payload(state).await) - } - "local_bind_start" => match start_bind(state).await { - Ok(payload) => tool_success_response(id, payload), - Err((_status, message)) => tool_error_response(id, message), - }, - "local_bind_exchange" => { - let parsed = match args { - Some(value) => match serde_json::from_value::(value) { - Ok(parsed) => parsed, - Err(err) => { - return tool_error_response( - id, - format!("invalid local_bind_exchange args: {err}"), - ); - } - }, - None => BindExchangeArgs::default(), - }; - match exchange_bind(state, parsed.bind_id, parsed.device_code).await { - Ok((_status, payload)) => tool_success_response(id, payload), - Err((_status, message)) => tool_error_response(id, message), - } - } - _ => tool_error_response(id, format!("unknown bootstrap tool `{tool_name}`")), - } -} - -fn bootstrap_tool_allowed_in_phase(tool_name: &str, phase: GatewayPhase) -> bool { - is_status_tool(tool_name) || (phase != GatewayPhase::Ready && is_bind_tool(tool_name)) -} - -fn is_status_tool(tool_name: &str) -> bool { - matches!( - tool_name, - "local_status" | "local_unlock_status" | "local_onboarding_info" - ) -} - -fn is_bind_tool(tool_name: &str) -> bool { - matches!(tool_name, "local_bind_start" | "local_bind_exchange") -} - -fn is_bootstrap_tool(tool_name: &str) -> bool { - is_status_tool(tool_name) || is_bind_tool(tool_name) -} - -fn is_ready_tool(tool_name: &str) -> bool { - matches!( - tool_name, - "secrets_find" - | "secrets_search" - | "secrets_history" - | "secrets_overview" - | "secrets_delete" - | LOCAL_EXEC_TOOL - ) -} - -fn not_ready_message(status: &Value) -> String { - let onboarding_url = status - .get("onboarding_url") - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let state_name = status - .get("state") - .and_then(|v| v.as_str()) - .unwrap_or("bootstrap"); - format!( - "local MCP is not ready (state: {state_name}). Use local_status/local_bind_start first and ask the user to complete onboarding at {onboarding_url}" - ) -} - -async fn handle_ready_tool( - state: &AppState, - tool_name: &str, - id: Value, - args: Option, -) -> Response { - let api_key = { - let guard = state.cache.read().await; - match guard.bound.as_ref() { - Some(bound) => bound.api_key.clone(), - None => return tool_error_response(id, "local MCP is not bound"), - } - }; - let args_value = args.unwrap_or_else(|| json!({})); - let result = match tool_name { - "secrets_find" => state.remote.entries_find(&api_key, &args_value).await, - "secrets_search" => state.remote.entries_search(&api_key, &args_value).await, - "secrets_history" => state.remote.entry_history(&api_key, &args_value).await, - "secrets_overview" => state.remote.entries_overview(&api_key).await, - "secrets_delete" => { - if args_value.get("dry_run").and_then(|value| value.as_bool()) != Some(true) { - return tool_error_response( - id, - "secrets_delete is exposed in local mode only for dry_run=true previews", - ); - } - state.remote.delete_preview(&api_key, &args_value).await - } - LOCAL_EXEC_TOOL => return handle_target_exec(state, id, Some(args_value)).await, - _ => return tool_error_response(id, format!("unknown ready tool `{tool_name}`")), - }; - match result { - Ok(value) => tool_success_response(id, value), - Err(err) => tool_error_response(id, err.to_string()), - } -} - -pub async fn handle_mcp(State(state): State, body: Body) -> Result { - let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await { - Ok(bytes) => bytes, - Err(_) => return Ok(invalid_request_response("invalid request body")), - }; - let request: Value = match serde_json::from_slice(&body_bytes) { - Ok(request) => request, - Err(err) => { - return Ok(invalid_request_response(format!( - "invalid json body: {err}" - ))); - } - }; - let method = request - .get("method") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let id = request.get("id").cloned().unwrap_or(json!(null)); - let (phase, status) = current_phase_and_status(&state).await; - - let response = match method { - "initialize" => initialize_response(id, phase), - "notifications/initialized" => empty_notification_response(), - "tools/list" => jsonrpc_result_response(id, json!({ "tools": tools_for_phase(phase) })), - "tools/call" => { - let params = request.get("params").cloned().unwrap_or_else(|| json!({})); - let tool_name = params - .get("name") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let args = params.get("arguments").cloned(); - if is_bootstrap_tool(tool_name) { - if !bootstrap_tool_allowed_in_phase(tool_name, phase) { - tool_error_response( - id, - "local MCP is already ready; binding tools are disabled until you explicitly unbind", - ) - } else { - handle_bootstrap_tool(&state, tool_name, id, args).await - } - } else if phase != GatewayPhase::Ready { - tool_error_response(id, not_ready_message(&status)) - } else if is_ready_tool(tool_name) { - handle_ready_tool(&state, tool_name, id, args).await - } else { - tool_error_response( - id, - format!("tool `{tool_name}` is not exposed by local policy"), - ) - } - } - "ping" => jsonrpc_result_response(id, json!({})), - _ => method_not_found(id, method), - }; - Ok(response) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cache::{BoundState, UnlockState, new_cache}; - use crate::config::LocalConfig; - use crate::remote::RemoteClient; - use crate::server::AppState; - use std::sync::Arc; - use std::time::Duration; - use url::Url; - use uuid::Uuid; - - fn test_state() -> AppState { - AppState { - config: LocalConfig { - bind: "127.0.0.1:9316".parse().unwrap(), - remote_base_url: Url::parse("https://example.com").unwrap(), - default_unlock_ttl: Duration::from_secs(3600), - default_exec_context_ttl: Duration::from_secs(3600), - }, - cache: new_cache(), - remote: Arc::new( - RemoteClient::new(Url::parse("https://example.com").unwrap()).unwrap(), - ), - } - } - - #[test] - fn bootstrap_phase_hides_ready_tools() { - let tools = tools_for_phase(GatewayPhase::Bootstrap); - let names: Vec<_> = tools - .iter() - .filter_map(|tool| tool.get("name").and_then(|value| value.as_str())) - .collect(); - assert!(names.contains(&"local_status")); - assert!(names.contains(&"local_bind_start")); - assert!(!names.contains(&"secrets_find")); - assert!(!names.contains(&LOCAL_EXEC_TOOL)); - } - - #[tokio::test] - async fn initialize_succeeds_when_unbound() { - let response = handle_mcp( - State(test_state()), - Body::from( - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {} - }) - .to_string(), - ), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - } - - #[tokio::test] - async fn tools_list_returns_bootstrap_tools_when_unbound() { - let response = handle_mcp( - State(test_state()), - Body::from( - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - }) - .to_string(), - ), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let value: Value = serde_json::from_slice(&bytes).unwrap(); - let names: Vec<_> = value["result"]["tools"] - .as_array() - .unwrap() - .iter() - .filter_map(|tool| tool.get("name").and_then(|name| name.as_str())) - .collect(); - assert!(names.contains(&"local_status")); - assert!(names.contains(&"local_bind_exchange")); - assert!(!names.contains(&"secrets_find")); - } - - #[tokio::test] - async fn tools_list_in_ready_phase_exposes_business_tools() { - let state = test_state(); - { - let mut guard = state.cache.write().await; - guard.bound = Some(BoundState { - user_id: Uuid::nil(), - api_key: "api-key".to_string(), - key_salt_hex: None, - key_check_hex: None, - key_params: None, - key_version: 0, - bound_at: Instant::now(), - }); - guard.unlock = Some(UnlockState { - encryption_key_hex: "11".repeat(32), - expires_at: Instant::now() + Duration::from_secs(600), - last_used_at: Instant::now(), - }); - } - let response = handle_mcp( - State(state), - Body::from( - json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/list", - "params": {} - }) - .to_string(), - ), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let value: Value = serde_json::from_slice(&bytes).unwrap(); - let names: Vec<_> = value["result"]["tools"] - .as_array() - .unwrap() - .iter() - .filter_map(|tool| tool.get("name").and_then(|name| name.as_str())) - .collect(); - assert!(names.contains(&"local_status")); - assert!(names.contains(&"secrets_find")); - assert!(names.contains(&LOCAL_EXEC_TOOL)); - assert!(!names.contains(&"local_bind_start")); - } - - #[tokio::test] - async fn tools_call_rejects_bind_start_when_ready() { - let state = test_state(); - { - let mut guard = state.cache.write().await; - guard.bound = Some(BoundState { - user_id: Uuid::nil(), - api_key: "api-key".to_string(), - key_salt_hex: None, - key_check_hex: None, - key_params: None, - key_version: 0, - bound_at: Instant::now(), - }); - guard.unlock = Some(UnlockState { - encryption_key_hex: "11".repeat(32), - expires_at: Instant::now() + Duration::from_secs(600), - last_used_at: Instant::now(), - }); - } - let response = handle_mcp( - State(state), - Body::from( - json!({ - "jsonrpc": "2.0", - "id": 4, - "method": "tools/call", - "params": { - "name": "local_bind_start", - "arguments": {} - } - }) - .to_string(), - ), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let value: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(value["result"]["isError"], Value::Bool(true)); - assert!(value.get("error").is_none()); - } - - #[tokio::test] - async fn tool_error_response_uses_mcp_tool_result_shape() { - let response = tool_error_response(json!(9), "boom"); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let value: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(value["id"], json!(9)); - assert_eq!(value["result"]["isError"], Value::Bool(true)); - assert_eq!(value["result"]["content"][0]["text"], json!("boom")); - assert!(value.get("error").is_none()); - } -} diff --git a/crates/secrets-mcp-local/src/remote.rs b/crates/secrets-mcp-local/src/remote.rs deleted file mode 100644 index 1620cff..0000000 --- a/crates/secrets-mcp-local/src/remote.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, Result, anyhow}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use url::Url; -use uuid::Uuid; - -#[derive(Clone)] -pub struct RemoteClient { - pub http_client: reqwest::Client, - pub remote_base_url: Url, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct BindStartResponse { - pub bind_id: String, - pub device_code: String, - pub approve_url: String, - pub expires_in_secs: u64, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct BindExchangeResponse { - pub status: Option, - pub user_id: Option, - pub api_key: Option, - pub key_salt_hex: Option, - pub key_check_hex: Option, - pub key_params: Option, - pub key_version: Option, -} - -#[derive(Debug)] -pub struct BindExchangeResult { - pub status: u16, - pub body: Value, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct BindRefreshResponse { - pub user_id: Uuid, - pub key_salt_hex: Option, - pub key_check_hex: Option, - pub key_params: Option, - pub key_version: i64, -} - -#[derive(Debug)] -pub struct BindRefreshResult { - pub status: u16, - pub body: Option, -} - -impl RemoteClient { - pub fn new(remote_base_url: Url) -> Result { - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .context("failed to build HTTP client")?; - Ok(Self { - http_client, - remote_base_url, - }) - } - - fn authed_request( - &self, - method: reqwest::Method, - path: &str, - api_key: &str, - encryption_key_hex: Option<&str>, - ) -> reqwest::RequestBuilder { - let mut url = self.remote_base_url.clone(); - url.set_path(path); - let mut req = self - .http_client - .request(method, url.as_str()) - .bearer_auth(api_key) - .header(reqwest::header::ACCEPT, "application/json"); - if let Some(key) = encryption_key_hex { - req = req.header("X-Encryption-Key", key); - } - req - } - - async fn parse_json_response( - &self, - res: reqwest::Response, - label: &str, - ) -> Result { - let status = res.status(); - let bytes = res - .bytes() - .await - .with_context(|| format!("{label} body read failed"))?; - let value = if bytes.is_empty() { - Value::Null - } else { - serde_json::from_slice::(&bytes).unwrap_or_else(|_| { - Value::String(String::from_utf8_lossy(&bytes).trim().to_string()) - }) - }; - if !status.is_success() { - let message = value - .get("error") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| value.to_string()); - return Err(anyhow!("{label} failed ({}): {message}", status)); - } - Ok(value) - } - - pub async fn bind_start(&self) -> Result { - let mut url = self.remote_base_url.clone(); - url.set_path("/api/local-mcp/bind/start"); - let res = self - .http_client - .post(url.as_str()) - .send() - .await - .context("bind/start request failed")?; - if !res.status().is_success() { - return Err(anyhow!("bind/start failed: {}", res.status())); - } - res.json::() - .await - .context("invalid bind/start response") - } - - pub async fn bind_exchange( - &self, - bind_id: &str, - device_code: &str, - ) -> Result { - let mut url = self.remote_base_url.clone(); - url.set_path("/api/local-mcp/bind/exchange"); - let res = self - .http_client - .post(url.as_str()) - .json(&serde_json::json!({ - "bind_id": bind_id, - "device_code": device_code, - })) - .send() - .await - .context("bind/exchange request failed")?; - let status = res.status().as_u16(); - let bytes = res - .bytes() - .await - .context("bind/exchange body read failed")?; - let body = if bytes.is_empty() { - Value::Null - } else { - serde_json::from_slice::(&bytes).unwrap_or_else(|_| { - Value::String(String::from_utf8_lossy(&bytes).trim().to_string()) - }) - }; - Ok(BindExchangeResult { status, body }) - } - - pub async fn bind_refresh(&self, api_key: &str) -> Result { - let mut url = self.remote_base_url.clone(); - url.set_path("/api/local-mcp/bind/refresh"); - let res = self - .http_client - .post(url.as_str()) - .header( - axum::http::header::AUTHORIZATION, - format!("Bearer {api_key}"), - ) - .send() - .await - .context("bind/refresh request failed")?; - let status = res.status().as_u16(); - if !res.status().is_success() { - return Ok(BindRefreshResult { status, body: None }); - } - let body = res - .json::() - .await - .context("invalid bind/refresh response")?; - Ok(BindRefreshResult { - status, - body: Some(body), - }) - } - - async fn post_api_json( - &self, - api_key: &str, - encryption_key_hex: Option<&str>, - path: &str, - body: &Value, - ) -> Result { - let res = self - .authed_request(reqwest::Method::POST, path, api_key, encryption_key_hex) - .json(body) - .send() - .await - .with_context(|| format!("{path} request failed"))?; - self.parse_json_response(res, path).await - } - - async fn get_api_json( - &self, - api_key: &str, - encryption_key_hex: Option<&str>, - path: &str, - ) -> Result { - let req = self.authed_request(reqwest::Method::GET, path, api_key, encryption_key_hex); - let res = req - .send() - .await - .with_context(|| format!("{path} request failed"))?; - Ok(res) - } - - pub async fn entries_find(&self, api_key: &str, args: &Value) -> Result { - self.post_api_json(api_key, None, "/api/local-mcp/entries/find", args) - .await - } - - pub async fn entries_search(&self, api_key: &str, args: &Value) -> Result { - self.post_api_json(api_key, None, "/api/local-mcp/entries/search", args) - .await - } - - pub async fn entry_history(&self, api_key: &str, args: &Value) -> Result { - self.post_api_json(api_key, None, "/api/local-mcp/entries/history", args) - .await - } - - pub async fn entries_overview(&self, api_key: &str) -> Result { - let res = self - .get_api_json(api_key, None, "/api/local-mcp/entries/overview") - .await?; - self.parse_json_response(res, "/api/local-mcp/entries/overview") - .await - } - - pub async fn delete_preview(&self, api_key: &str, args: &Value) -> Result { - self.post_api_json(api_key, None, "/api/local-mcp/entries/delete-preview", args) - .await - } - - pub async fn get_entry_secrets_by_id( - &self, - api_key: &str, - encryption_key_hex: &str, - entry_id: &str, - ) -> Result> { - let path = format!("/api/local-mcp/entries/{entry_id}/secrets"); - let res = self - .get_api_json(api_key, Some(encryption_key_hex), &path) - .await?; - let value = self.parse_json_response(res, &path).await?; - serde_json::from_value::>(value) - .context("invalid decrypt payload from remote HTTP API") - } -} diff --git a/crates/secrets-mcp-local/src/server.rs b/crates/secrets-mcp-local/src/server.rs deleted file mode 100644 index 3cbc5fa..0000000 --- a/crates/secrets-mcp-local/src/server.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::sync::Arc; - -use axum::Router; -use axum::extract::State; -use axum::response::{Html, IntoResponse}; -use axum::routing::{get, post}; - -use crate::cache::SharedCache; -use crate::config::LocalConfig; -use crate::remote::RemoteClient; - -#[derive(Clone)] -pub struct AppState { - pub config: LocalConfig, - pub cache: SharedCache, - pub remote: Arc, -} - -async fn index(State(state): State) -> impl IntoResponse { - Html(format!( - r#" - - - - secrets-mcp-local onboarding - - - -

secrets-mcp-local

-

本地 MCP 地址:http://{bind}/mcp

-

远端服务地址:{remote}

- -
-

当前状态

-
loading...
-
- - - 打开解锁页 - -
-
- -
-

步骤 1:远端授权

-

点击“开始绑定”后,这里会显示授权地址。

-
-
- -
-

步骤 2:本地解锁

-

授权完成后,本页会自动切换到解锁阶段。你也可以直接在下方完成解锁。

- -
- -
-

接入 Cursor

-

把 MCP 地址配置为 http://{bind}/mcp。在未就绪时,AI 只会看到 bootstrap 工具;完成授权和解锁后会自动暴露业务工具。

-
- - - -"#, - bind = state.config.bind, - remote = state.config.remote_base_url, - )) -} - -pub fn router(state: AppState) -> Router { - Router::new() - .route("/", get(index)) - .route("/mcp", axum::routing::any(crate::mcp::handle_mcp)) - .route("/local/bind/start", post(crate::bind::bind_start)) - .route("/local/bind/exchange", post(crate::bind::bind_exchange)) - .route("/local/unbind", post(crate::bind::unbind)) - .route("/unlock", get(crate::unlock::unlock_page)) - .route( - "/local/unlock/complete", - post(crate::unlock::unlock_complete), - ) - .route("/local/lock", post(crate::unlock::lock)) - .route("/local/status", get(crate::unlock::status)) - .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)) - .with_state(state) -} diff --git a/crates/secrets-mcp-local/src/unlock.rs b/crates/secrets-mcp-local/src/unlock.rs deleted file mode 100644 index 5037b01..0000000 --- a/crates/secrets-mcp-local/src/unlock.rs +++ /dev/null @@ -1,265 +0,0 @@ -use std::time::Instant; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::{Html, IntoResponse}; -use secrets_core::crypto::{decrypt, extract_key_from_hex, hex}; -use serde::Deserialize; -use serde_json::json; - -use crate::bind::refresh_bound_state; -use crate::cache::UnlockState; -use crate::server::AppState; - -const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-mcp-key-check"; - -fn verify_key_check_hex(key_hex: &str, key_check_hex: &str) -> Result<(), (StatusCode, String)> { - let key_check = hex::decode_hex(key_check_hex).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("invalid key_check hex: {e}"), - ) - })?; - let user_key = extract_key_from_hex(key_hex).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("invalid encryption key: {e}"), - ) - })?; - let plaintext = decrypt(&user_key, &key_check) - .map_err(|_| (StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()))?; - if plaintext != KEY_CHECK_PLAINTEXT { - return Err((StatusCode::UNAUTHORIZED, "wrong passphrase".to_string())); - } - Ok(()) -} - -#[derive(Deserialize)] -pub struct UnlockCompleteBody { - encryption_key: String, - ttl_secs: Option, -} - -pub async fn unlock_page(State(state): State) -> impl IntoResponse { - refresh_bound_state(&state).await; - let bound = { - let guard = state.cache.read().await; - guard.bound.clone() - }; - let Some(mut bound) = bound else { - return Html( - "

Not bound

Run /local/bind/start and complete approve first.

" - .to_string(), - ); - }; - { - let guard = state.cache.read().await; - if let Some(updated) = guard.bound.clone() { - bound = updated; - } - } - let key_salt_hex = bound.key_salt_hex.as_deref().unwrap_or(""); - let key_check_hex = bound.key_check_hex.as_deref().unwrap_or(""); - let iterations = bound - .key_params - .as_ref() - .and_then(|v| v.get("iterations")) - .and_then(|n| n.as_u64()) - .unwrap_or(600_000); - - Html(format!( - r#" - -Local MCP Unlock - -

解锁本地 MCP

-

用户:{user_id}

- - - -

-
-
-"#,
-        user_id = bound.user_id,
-        ttl = state.config.default_unlock_ttl.as_secs(),
-        salt = key_salt_hex,
-        key_check = key_check_hex,
-        iter = iterations
-    ))
-}
-
-pub async fn unlock_complete(
-    State(state): State,
-    axum::Json(input): axum::Json,
-) -> Result {
-    let key = input.encryption_key.trim();
-    if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
-        return Err((
-            StatusCode::BAD_REQUEST,
-            "encryption_key must be 64 hex chars".to_string(),
-        ));
-    }
-    let ttl = std::time::Duration::from_secs(
-        input
-            .ttl_secs
-            .unwrap_or(state.config.default_unlock_ttl.as_secs())
-            .clamp(60, 86400 * 7),
-    );
-    let mut guard = state.cache.write().await;
-    let Some(bound) = guard.bound.as_ref() else {
-        return Err((StatusCode::UNAUTHORIZED, "not bound".to_string()));
-    };
-    let key_check_hex = bound
-        .key_check_hex
-        .as_deref()
-        .ok_or((StatusCode::BAD_REQUEST, "key_check missing".to_string()))?;
-    verify_key_check_hex(key, key_check_hex)?;
-    guard.exec_contexts.clear();
-    guard.unlock = Some(UnlockState {
-        encryption_key_hex: key.to_string(),
-        expires_at: Instant::now() + ttl,
-        last_used_at: Instant::now(),
-    });
-    Ok((
-        StatusCode::OK,
-        axum::Json(json!({"ok": true, "ttl_secs": ttl.as_secs()})),
-    ))
-}
-
-pub async fn lock(State(state): State) -> impl IntoResponse {
-    let mut guard = state.cache.write().await;
-    guard.clear_unlock_and_exec();
-    (StatusCode::OK, axum::Json(json!({"ok": true})))
-}
-
-pub async fn status(State(state): State) -> impl IntoResponse {
-    let payload = status_payload(&state).await;
-    (StatusCode::OK, axum::Json(payload))
-}
-
-pub async fn status_payload(state: &AppState) -> serde_json::Value {
-    refresh_bound_state(state).await;
-    let now = Instant::now();
-    let mut guard = state.cache.write().await;
-    let unlocked = guard
-        .unlock
-        .as_ref()
-        .is_some_and(|u| u.expires_at > now && !u.encryption_key_hex.is_empty());
-    let expires_in_secs = guard
-        .unlock
-        .as_ref()
-        .and_then(|u| (u.expires_at > now).then_some(u.expires_at.duration_since(now).as_secs()));
-    if guard.unlock.as_ref().is_some_and(|u| u.expires_at <= now) {
-        guard.clear_unlock_and_exec();
-    }
-    let state_name = guard.phase(now);
-    let bound = guard.bound.as_ref().map(|b| {
-        json!({
-            "user_id": b.user_id,
-            "key_version": b.key_version,
-            "bound_for_secs": b.bound_at.elapsed().as_secs(),
-        })
-    });
-    let pending_bind = guard.pending_bind.as_ref().map(|pending| {
-        json!({
-            "bind_id": pending.bind_id,
-            "device_code": pending.device_code,
-            "approve_url": pending.approve_url,
-            "expires_in_secs": pending.expires_at.saturating_duration_since(now).as_secs(),
-            "started_for_secs": pending.started_at.elapsed().as_secs(),
-        })
-    });
-    json!({
-        "state": state_name,
-        "bound": bound,
-        "pending_bind": pending_bind,
-        "unlocked": unlocked,
-        "expires_in_secs": expires_in_secs,
-        "cached_targets": guard.exec_contexts.len(),
-        "onboarding_url": format!("http://{}/", state.config.bind),
-        "unlock_url": format!("http://{}/unlock", state.config.bind),
-        "mcp_url": format!("http://{}/mcp", state.config.bind),
-    })
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use secrets_core::crypto::encrypt;
-
-    #[test]
-    fn verify_key_check_accepts_matching_key() {
-        let key_hex = "11".repeat(32);
-        let key = extract_key_from_hex(&key_hex).unwrap();
-        let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
-        let ciphertext_hex = hex::encode_hex(&ciphertext);
-        assert!(verify_key_check_hex(&key_hex, &ciphertext_hex).is_ok());
-    }
-
-    #[test]
-    fn verify_key_check_rejects_wrong_key() {
-        let correct_key_hex = "11".repeat(32);
-        let wrong_key_hex = "22".repeat(32);
-        let key = extract_key_from_hex(&correct_key_hex).unwrap();
-        let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
-        let ciphertext_hex = hex::encode_hex(&ciphertext);
-        let err = verify_key_check_hex(&wrong_key_hex, &ciphertext_hex).unwrap_err();
-        assert_eq!(err.0, StatusCode::UNAUTHORIZED);
-    }
-}
diff --git a/crates/secrets-mcp/CHANGELOG.md b/crates/secrets-mcp/CHANGELOG.md
deleted file mode 100644
index d6f5a99..0000000
--- a/crates/secrets-mcp/CHANGELOG.md
+++ /dev/null
@@ -1,57 +0,0 @@
-本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
-
-## [0.6.0] - 2026-04-12
-
-### Changed
-
-- 重构 `secrets-mcp-local` 为本地 MCP 服务:`initialize` / `tools/list` 在未绑定、未解锁时也始终成功,不再通过连接级 `401` 让 MCP 客户端误判为服务离线。
-- 本地 gateway 改为三态工具面:`bootstrap` / `pendingUnlock` / `ready`;未就绪时仅暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info` 等 bootstrap 工具。
-- 本地首页改为真实 onboarding 页面:可直接发起绑定、展示 `approve_url`、轮询授权结果,并衔接本地 unlock;不再要求用户手工拼 `curl` 请求。
-- 本地绑定闭环改为持久化短时会话:远程 `secrets-mcp` 新增 `local_mcp_bind_sessions` 存储绑定确认状态,避免仅靠单进程内存状态。
-- 本地解锁增加 `key_check` 校验与生命周期收敛:浏览器内先验证密码短语,再缓存本地 unlock;当远程 `key_version` 变化、API key 失效或绑定用户缺失时,本地自动失效 unlock 或清除 bound 状态。
-- 远程 `secrets-mcp` 新增 `/api/local-mcp/entries/find|search|history|overview|delete-preview|{id}/secrets` JSON API;local gateway 的发现、预览删除与解密读取已切到这些 HTTP API,不再依赖远程 `/mcp` 作为运行时后端。
-- 本地 gateway 新增 `target_exec` 通用代执行能力:AI 可先发现服务器或 API 服务条目,再由 local gateway 内部读取条目密钥并注入 `TARGET_*` 环境变量执行标准命令;执行上下文按 `entry_id` 本地缓存,可在 unlock 生命周期内复用。
-
-## [0.5.28] - 2026-04-12
-
-### Added
-
-- 工作区新增 **`secrets-mcp-local`** 并升级为本地 MCP 服务:支持 `bind/start -> approve -> bind/exchange -> /unlock` 闭环,复用远程 Web 会话完成本地绑定,浏览器内派生后按 TTL 缓存解锁状态。
-- 远程 `secrets-mcp` 新增本地绑定 API:`/api/local-mcp/bind/start`、`/api/local-mcp/bind/approve`、`/api/local-mcp/bind/exchange` 以及确认页 `/local-mcp/approve`。
-
-## [0.5.27] - 2026-04-11
-
-### Added
-
-- Web **`/entries`**:按 **tags** 筛选(逗号分隔、trim、多标签 **AND** 语义,与 `SearchParams` / MCP 一致);folder 标签计数、分页与筛选栏状态同步保留 `tags`。
-
-## [0.5.26] - 2026-04-11
-
-### Fixed
-
-- **Google OAuth**:工作区 `reqwest` 此前关闭默认特性且未启用 **`system-proxy`**,进程不读取 macOS/Windows 系统代理,易出现与浏览器不一致(本机可上 Google 但换 token 超时)。已显式启用 `system-proxy`。
-
-## [0.5.25] - 2026-04-11
-
-### Changed
-
-- Google OAuth:token / userinfo 请求单独 **45s** 超时(避免仅触达默认客户端 15s);失败时区分超时、连接错误,并在非 2xx 时记录/返回 Google 响应体片段(如 `invalid_grant`、`redirect_uri_mismatch`)。
-
-## [0.5.24] - 2026-04-11
-
-### Changed
-
-- 首页页脚将原「登录」入口改为「变更记录」(`/changelog`);顶部导航仍保留登录 / 进入控制台。
-
-## [0.5.23] - 2026-04-11
-
-### Added
-
-- Changelog 页使用 **Markdown** 渲染(`pulldown-cmark`:表格、~~删除线~~、任务列表等)。
-
-## [0.5.22] - 2026-04-11
-
-### Added
-
-- Dashboard(MCP)页脚版本旁增加「变更记录」链接,打开本变更说明页。
-
diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml
deleted file mode 100644
index 534345b..0000000
--- a/crates/secrets-mcp/Cargo.toml
+++ /dev/null
@@ -1,48 +0,0 @@
-[package]
-name = "secrets-mcp"
-version = "0.6.0"
-edition.workspace = true
-
-[[bin]]
-name = "secrets-mcp"
-path = "src/main.rs"
-
-[dependencies]
-secrets-core = { path = "../secrets-core" }
-
-# MCP
-rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
-
-# Web framework
-axum = "0.8"
-axum-extra = { version = "0.10", features = ["typed-header"] }
-tower = "0.5"
-tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
-tower-sessions = "0.14"
-tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
-governor = { version = "0.10", features = ["std", "jitter"] }
-time = "0.3"
-
-# OAuth (manual token exchange via reqwest)
-reqwest.workspace = true
-
-# Templating - render templates manually to avoid integration crate issues
-askama = "0.13"
-
-# Common
-anyhow.workspace = true
-chrono.workspace = true
-serde.workspace = true
-serde_json.workspace = true
-rand.workspace = true
-sqlx.workspace = true
-tokio.workspace = true
-tracing.workspace = true
-tracing-subscriber.workspace = true
-uuid.workspace = true
-dotenvy.workspace = true
-urlencoding = "2"
-schemars = "1"
-http = "1"
-url = "2"
-pulldown-cmark = "0.13.3"
diff --git a/crates/secrets-mcp/src/auth.rs b/crates/secrets-mcp/src/auth.rs
deleted file mode 100644
index 22fa3ec..0000000
--- a/crates/secrets-mcp/src/auth.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-use axum::{
-    extract::{Request, State},
-    http::StatusCode,
-    middleware::Next,
-    response::Response,
-};
-use sqlx::PgPool;
-use uuid::Uuid;
-
-use secrets_core::service::api_key::validate_api_key;
-
-use crate::client_ip;
-
-/// Injected into request extensions after Bearer token validation.
-#[derive(Clone, Debug)]
-pub struct AuthUser {
-    pub user_id: Uuid,
-}
-
-/// Axum middleware that validates Bearer API keys for the /mcp route.
-/// Passes all non-MCP paths through without authentication.
-pub async fn bearer_auth_middleware(
-    State(pool): State,
-    req: Request,
-    next: Next,
-) -> Result {
-    let path = req.uri().path();
-    let method = req.method().as_str();
-    let client_ip = client_ip::extract_client_ip(&req);
-
-    // Only authenticate /mcp paths
-    if !path.starts_with("/mcp") {
-        return Ok(next.run(req).await);
-    }
-
-    // Allow OPTIONS (CORS preflight) through
-    if req.method() == axum::http::Method::OPTIONS {
-        return Ok(next.run(req).await);
-    }
-
-    let auth_header = req
-        .headers()
-        .get(axum::http::header::AUTHORIZATION)
-        .and_then(|v| v.to_str().ok());
-
-    let raw_key = match auth_header {
-        Some(h) if h.starts_with("Bearer ") => h.trim_start_matches("Bearer ").trim(),
-        Some(_) => {
-            tracing::warn!(
-                method,
-                path,
-                %client_ip,
-                "invalid Authorization header format on /mcp (expected Bearer …)"
-            );
-            return Err(StatusCode::UNAUTHORIZED);
-        }
-        None => {
-            tracing::warn!(
-                method,
-                path,
-                %client_ip,
-                "missing Authorization header on /mcp"
-            );
-            return Err(StatusCode::UNAUTHORIZED);
-        }
-    };
-
-    match validate_api_key(&pool, raw_key).await {
-        Ok(Some(user_id)) => {
-            tracing::debug!(?user_id, "api key authenticated");
-            let mut req = req;
-            req.extensions_mut().insert(AuthUser { user_id });
-            Ok(next.run(req).await)
-        }
-        Ok(None) => {
-            tracing::warn!(
-                method,
-                path,
-                %client_ip,
-                key_prefix = %&raw_key.chars().take(12).collect::(),
-                key_len = raw_key.len(),
-                "invalid api key (not found in database — e.g. revoked key or DB was reset; update MCP client Bearer token)"
-            );
-            Err(StatusCode::UNAUTHORIZED)
-        }
-        Err(e) => {
-            tracing::error!(
-                method,
-                path,
-                %client_ip,
-                error = %e,
-                "api key validation error"
-            );
-            Err(StatusCode::INTERNAL_SERVER_ERROR)
-        }
-    }
-}
diff --git a/crates/secrets-mcp/src/client_ip.rs b/crates/secrets-mcp/src/client_ip.rs
deleted file mode 100644
index 3bb0772..0000000
--- a/crates/secrets-mcp/src/client_ip.rs
+++ /dev/null
@@ -1,85 +0,0 @@
-use axum::extract::Request;
-use std::net::{IpAddr, SocketAddr};
-
-/// Extract the client IP from a request.
-///
-/// When the `TRUST_PROXY` environment variable is set to `1` or `true`, the
-/// `X-Forwarded-For` and `X-Real-IP` headers are consulted first, which is
-/// appropriate when the service runs behind a trusted reverse proxy (e.g.
-/// Caddy). Otherwise — or if those headers are absent/empty — the direct TCP
-/// connection address from `ConnectInfo` is used.
-///
-/// **Important**: only enable `TRUST_PROXY` when the application is guaranteed
-/// to receive traffic exclusively through a controlled reverse proxy. Enabling
-/// it on a directly-exposed port allows clients to spoof their IP address and
-/// bypass per-IP rate limiting.
-pub fn extract_client_ip(req: &Request) -> String {
-    if trust_proxy_enabled() {
-        if let Some(ip) = forwarded_for_ip(req.headers()) {
-            return ip;
-        }
-        if let Some(ip) = real_ip(req.headers()) {
-            return ip;
-        }
-    }
-
-    connect_info_ip(req).unwrap_or_else(|| "unknown".to_string())
-}
-
-/// Extract the client IP from individual header map and socket address components.
-///
-/// This variant is used by handlers that receive headers and connect info as
-/// separate axum extractor parameters (e.g. OAuth callback handlers).
-/// The same `TRUST_PROXY` logic applies.
-pub fn extract_client_ip_parts(
-    headers: &axum::http::HeaderMap,
-    addr: std::net::SocketAddr,
-) -> String {
-    if trust_proxy_enabled() {
-        if let Some(ip) = forwarded_for_ip(headers) {
-            return ip;
-        }
-        if let Some(ip) = real_ip(headers) {
-            return ip;
-        }
-    }
-    addr.ip().to_string()
-}
-
-fn trust_proxy_enabled() -> bool {
-    static CACHE: std::sync::OnceLock = std::sync::OnceLock::new();
-    *CACHE.get_or_init(|| {
-        matches!(
-            std::env::var("TRUST_PROXY").as_deref(),
-            Ok("1") | Ok("true") | Ok("yes")
-        )
-    })
-}
-
-fn forwarded_for_ip(headers: &axum::http::HeaderMap) -> Option {
-    let value = headers.get("x-forwarded-for")?.to_str().ok()?;
-    let first = value.split(',').next()?.trim();
-    if first.is_empty() {
-        None
-    } else {
-        validate_ip(first)
-    }
-}
-
-fn real_ip(headers: &axum::http::HeaderMap) -> Option {
-    let value = headers.get("x-real-ip")?.to_str().ok()?;
-    let ip = value.trim();
-    if ip.is_empty() { None } else { validate_ip(ip) }
-}
-
-/// Validate that a string is a valid IP address.
-/// Returns Some(ip) if valid, None otherwise.
-fn validate_ip(s: &str) -> Option {
-    s.parse::().ok().map(|ip| ip.to_string())
-}
-
-fn connect_info_ip(req: &Request) -> Option {
-    req.extensions()
-        .get::>()
-        .map(|c| c.0.ip().to_string())
-}
diff --git a/crates/secrets-mcp/src/error.rs b/crates/secrets-mcp/src/error.rs
deleted file mode 100644
index 2311d11..0000000
--- a/crates/secrets-mcp/src/error.rs
+++ /dev/null
@@ -1,54 +0,0 @@
-use secrets_core::error::AppError;
-
-/// Map a structured `AppError` to an MCP protocol error.
-///
-/// This replaces the previous pattern of swallowing all errors into `-32603`.
-pub fn app_error_to_mcp(err: &AppError) -> rmcp::ErrorData {
-    match err {
-        AppError::ConflictSecretName { secret_name } => rmcp::ErrorData::invalid_request(
-            format!(
-                "A secret with the name '{secret_name}' already exists for your account. \
-                 Secret names must be unique per user."
-            ),
-            None,
-        ),
-        AppError::ConflictEntryName { folder, name } => rmcp::ErrorData::invalid_request(
-            format!(
-                "An entry with folder='{folder}' and name='{name}' already exists. \
-                 The combination of folder and name must be unique."
-            ),
-            None,
-        ),
-        AppError::NotFoundEntry => rmcp::ErrorData::invalid_request(
-            "Entry not found. Use secrets_find to discover existing entries.",
-            None,
-        ),
-        AppError::NotFoundUser => rmcp::ErrorData::invalid_request("User not found.", None),
-        AppError::NotFoundSecret => rmcp::ErrorData::invalid_request("Secret not found.", None),
-        AppError::AuthenticationFailed => rmcp::ErrorData::invalid_request(
-            "Authentication failed. Please check your API key or login credentials.",
-            None,
-        ),
-        AppError::Unauthorized => rmcp::ErrorData::invalid_request(
-            "Unauthorized: you do not have permission to access this resource.",
-            None,
-        ),
-        AppError::Validation { message } => rmcp::ErrorData::invalid_request(message.clone(), None),
-        AppError::ConcurrentModification => rmcp::ErrorData::invalid_request(
-            "The entry was modified by another request. Please refresh and try again.",
-            None,
-        ),
-        AppError::DecryptionFailed => rmcp::ErrorData::invalid_request(
-            "Decryption failed — the encryption key may be incorrect or does not match the data.",
-            None,
-        ),
-        AppError::EncryptionKeyNotSet => rmcp::ErrorData::invalid_request(
-            "Encryption key not set. You must set a passphrase before using this feature.",
-            None,
-        ),
-        AppError::Internal(_) => rmcp::ErrorData::internal_error(
-            "Request failed due to a server error. Check service logs if you need details.",
-            None,
-        ),
-    }
-}
diff --git a/crates/secrets-mcp/src/logging.rs b/crates/secrets-mcp/src/logging.rs
deleted file mode 100644
index 0f982ad..0000000
--- a/crates/secrets-mcp/src/logging.rs
+++ /dev/null
@@ -1,381 +0,0 @@
-use std::time::Instant;
-
-use axum::{
-    body::{Body, Bytes, to_bytes},
-    extract::Request,
-    http::{
-        HeaderMap, Method, StatusCode,
-        header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
-    },
-    middleware::Next,
-    response::{IntoResponse, Response},
-};
-
-use crate::auth::AuthUser;
-
-/// Axum middleware that logs structured info for every HTTP request.
-///
-/// All requests: method, path, status, latency_ms, client_ip, user_agent.
-/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
-/// tool_name, jsonrpc_id, mcp_session, batch_size, tool_args (non-sensitive
-/// arguments only), plus masked auth_key / enc_key fingerprints and user_id
-/// for diagnosing header forwarding issues.
-///
-/// Sensitive headers (Authorization, X-Encryption-Key) are never logged in
-/// full — only short fingerprints are emitted.
-pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
-    let method = req.method().clone();
-    let path = req.uri().path().to_string();
-    let ip = client_ip(&req);
-    let ua = header_str(req.headers(), USER_AGENT);
-    let content_len = header_str(req.headers(), CONTENT_LENGTH).and_then(|v| v.parse::().ok());
-    let mcp_session = req
-        .headers()
-        .get("mcp-session-id")
-        .or_else(|| req.headers().get("x-mcp-session"))
-        .and_then(|v| v.to_str().ok())
-        .map(|s| s.to_string());
-
-    // Capture header fingerprints before consuming the request.
-    let auth_key = mask_bearer(req.headers());
-    let enc_key = mask_enc_key(req.headers());
-
-    let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
-    let is_json = header_str(req.headers(), CONTENT_TYPE)
-        .map(|ct| ct.contains("application/json"))
-        .unwrap_or(false);
-
-    let start = Instant::now();
-
-    // For MCP JSON-RPC POST requests, buffer body to extract JSON-RPC metadata.
-    // We cap at 512 KiB to avoid buffering large payloads.
-    if is_mcp_post && is_json {
-        let cap = content_len.unwrap_or(0);
-        if cap <= 512 * 1024 {
-            let (parts, body) = req.into_parts();
-            // user_id is available after auth middleware has run (injected into extensions).
-            let user_id = parts
-                .extensions
-                .get::()
-                .map(|a| a.user_id.to_string());
-            match to_bytes(body, 512 * 1024).await {
-                Ok(bytes) => {
-                    let rpc = parse_jsonrpc_meta(&bytes);
-                    let req = Request::from_parts(parts, Body::from(bytes));
-                    let resp = next.run(req).await;
-                    let status = resp.status().as_u16();
-                    let elapsed = start.elapsed().as_millis();
-                    log_mcp_request(
-                        &method,
-                        &path,
-                        status,
-                        elapsed,
-                        ip.as_deref(),
-                        ua.as_deref(),
-                        content_len,
-                        mcp_session.as_deref(),
-                        auth_key.as_deref(),
-                        &enc_key,
-                        user_id.as_deref(),
-                        &rpc,
-                    );
-                    return resp;
-                }
-                Err(e) => {
-                    tracing::warn!(path, error = %e, "failed to buffer MCP request body for logging");
-                    let elapsed = start.elapsed().as_millis();
-                    tracing::info!(
-                        method = method.as_str(),
-                        path,
-                        status = StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
-                        elapsed_ms = elapsed,
-                        client_ip = ip.as_deref(),
-                        ua = ua.as_deref(),
-                        content_length = content_len,
-                        mcp_session = mcp_session.as_deref(),
-                        auth_key = auth_key.as_deref(),
-                        enc_key = enc_key.as_str(),
-                        user_id = user_id.as_deref(),
-                        "mcp request",
-                    );
-                    return (
-                        StatusCode::INTERNAL_SERVER_ERROR,
-                        "failed to read request body",
-                    )
-                        .into_response();
-                }
-            }
-        }
-    }
-
-    let resp = next.run(req).await;
-    let status = resp.status().as_u16();
-    let elapsed = start.elapsed().as_millis();
-
-    // Known client probe patterns that legitimately 404 — downgrade to debug to
-    // avoid noise in production logs.  These are:
-    //   • GET /.well-known/* — OAuth/OIDC discovery by MCP clients (RFC 8414 / RFC 9728)
-    //   • GET /mcp → 404    — old SSE-transport compatibility probe by clients
-    let is_expected_probe_404 = status == 404
-        && (path.starts_with("/.well-known/")
-            || (method == Method::GET && path.starts_with("/mcp")));
-
-    if is_expected_probe_404 {
-        tracing::debug!(
-            method = method.as_str(),
-            path,
-            status,
-            elapsed_ms = elapsed,
-            client_ip = ip.as_deref(),
-            ua = ua.as_deref(),
-            "probe request (not found — expected)",
-        );
-    } else {
-        log_http_request(
-            &method,
-            &path,
-            status,
-            elapsed,
-            ip.as_deref(),
-            ua.as_deref(),
-            content_len,
-        );
-    }
-
-    resp
-}
-
-// ── Logging helpers ───────────────────────────────────────────────────────────
-
-fn log_http_request(
-    method: &Method,
-    path: &str,
-    status: u16,
-    elapsed_ms: u128,
-    client_ip: Option<&str>,
-    ua: Option<&str>,
-    content_length: Option,
-) {
-    tracing::info!(
-        method = method.as_str(),
-        path,
-        status,
-        elapsed_ms,
-        client_ip,
-        ua,
-        content_length,
-        "http request",
-    );
-}
-
-#[allow(clippy::too_many_arguments)]
-fn log_mcp_request(
-    method: &Method,
-    path: &str,
-    status: u16,
-    elapsed_ms: u128,
-    client_ip: Option<&str>,
-    ua: Option<&str>,
-    content_length: Option,
-    mcp_session: Option<&str>,
-    auth_key: Option<&str>,
-    enc_key: &str,
-    user_id: Option<&str>,
-    rpc: &JsonRpcMeta,
-) {
-    tracing::info!(
-        method = method.as_str(),
-        path,
-        status,
-        elapsed_ms,
-        client_ip,
-        ua,
-        content_length,
-        mcp_session,
-        jsonrpc = rpc.rpc_method.as_deref(),
-        tool = rpc.tool_name.as_deref(),
-        jsonrpc_id = rpc.request_id.as_deref(),
-        batch_size = rpc.batch_size,
-        tool_args = rpc.tool_args.as_deref(),
-        auth_key,
-        enc_key,
-        user_id,
-        "mcp request",
-    );
-}
-
-// ── Sensitive header masking ──────────────────────────────────────────────────
-
-/// Mask a Bearer token: emit only the first 12 characters followed by `…`.
-/// Returns `None` if the Authorization header is absent or not a Bearer token.
-/// Example: `sk_90c88844e4e5…`
-fn mask_bearer(headers: &HeaderMap) -> Option {
-    let val = headers.get(AUTHORIZATION)?.to_str().ok()?;
-    let token = val.strip_prefix("Bearer ")?.trim();
-    if token.is_empty() {
-        return None;
-    }
-    if token.len() > 12 {
-        Some(format!("{}…", &token[..12]))
-    } else {
-        Some(token.to_string())
-    }
-}
-
-/// Fingerprint the X-Encryption-Key header.
-///
-/// Emits first 4 chars, last 4 chars, and raw byte length, e.g. `146b…5516(64)`.
-/// Returns `"absent"` when the header is missing. Reveals enough to confirm
-/// which key arrived and whether it was truncated or padded, without revealing
-/// the full value.
-fn mask_enc_key(headers: &HeaderMap) -> String {
-    match headers
-        .get("x-encryption-key")
-        .and_then(|v| v.to_str().ok())
-    {
-        Some(val) => {
-            let raw_len = val.len();
-            let t = val.trim();
-            let len = t.len();
-            if len >= 8 {
-                let prefix = &t[..4];
-                let suffix = &t[len - 4..];
-                if raw_len != len {
-                    // Trailing/leading whitespace detected — extra diagnostic.
-                    format!("{prefix}…{suffix}({len}, raw={raw_len})")
-                } else {
-                    format!("{prefix}…{suffix}({len})")
-                }
-            } else {
-                format!("…({len})")
-            }
-        }
-        None => "absent".to_string(),
-    }
-}
-
-// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
-
-/// Safe (non-sensitive) argument keys that may be included verbatim in logs.
-/// Keys NOT in this list (e.g. `secrets`, `secrets_obj`, `meta_obj`,
-/// `encryption_key`) are silently dropped.
-const SAFE_ARG_KEYS: &[&str] = &[
-    "id",
-    "name",
-    "name_query",
-    "folder",
-    "type",
-    "entry_type",
-    "field",
-    "query",
-    "tags",
-    "limit",
-    "offset",
-    "format",
-    "dry_run",
-    "prefix",
-];
-
-#[derive(Debug, Default)]
-struct JsonRpcMeta {
-    request_id: Option,
-    rpc_method: Option,
-    tool_name: Option,
-    batch_size: Option,
-    /// Non-sensitive tool call arguments for diagnostic logging.
-    tool_args: Option,
-}
-
-fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
-    let Ok(value) = serde_json::from_slice::(bytes) else {
-        return JsonRpcMeta::default();
-    };
-
-    if let Some(arr) = value.as_array() {
-        // Batch request: summarise method(s) from first element only
-        let first = arr.first().map(parse_single).unwrap_or_default();
-        return JsonRpcMeta {
-            batch_size: Some(arr.len()),
-            ..first
-        };
-    }
-
-    parse_single(&value)
-}
-
-fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
-    let request_id = value.get("id").and_then(json_to_string);
-    let rpc_method = value
-        .get("method")
-        .and_then(|v| v.as_str())
-        .map(|s| s.to_string());
-    let tool_name = value
-        .pointer("/params/name")
-        .and_then(|v| v.as_str())
-        .map(|s| s.to_string());
-    let tool_args = extract_tool_args(value);
-
-    JsonRpcMeta {
-        request_id,
-        rpc_method,
-        tool_name,
-        batch_size: None,
-        tool_args,
-    }
-}
-
-/// Extract a compact summary of non-sensitive tool arguments for logging.
-/// Only keys listed in `SAFE_ARG_KEYS` are included.
-fn extract_tool_args(value: &serde_json::Value) -> Option {
-    let args = value.pointer("/params/arguments")?;
-    let obj = args.as_object()?;
-    let pairs: Vec = obj
-        .iter()
-        .filter(|(k, v)| SAFE_ARG_KEYS.contains(&k.as_str()) && !v.is_null())
-        .map(|(k, v)| format!("{}={}", k, summarize_value(v)))
-        .collect();
-    if pairs.is_empty() {
-        None
-    } else {
-        Some(pairs.join(" "))
-    }
-}
-
-/// Produce a short, log-safe representation of a JSON value.
-fn summarize_value(v: &serde_json::Value) -> String {
-    match v {
-        serde_json::Value::String(s) => {
-            if s.len() > 64 {
-                format!("\"{}…\"", &s[..64])
-            } else {
-                format!("\"{s}\"")
-            }
-        }
-        serde_json::Value::Array(arr) => format!("[…{}]", arr.len()),
-        serde_json::Value::Object(_) => "{…}".to_string(),
-        other => other.to_string(),
-    }
-}
-
-fn json_to_string(value: &serde_json::Value) -> Option {
-    match value {
-        serde_json::Value::Null => None,
-        serde_json::Value::String(s) => Some(s.clone()),
-        serde_json::Value::Number(n) => Some(n.to_string()),
-        serde_json::Value::Bool(b) => Some(b.to_string()),
-        other => Some(other.to_string()),
-    }
-}
-
-// ── Header helpers ────────────────────────────────────────────────────────────
-
-fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option {
-    headers
-        .get(name)
-        .and_then(|v| v.to_str().ok())
-        .map(|s| s.to_string())
-}
-
-fn client_ip(req: &Request) -> Option {
-    crate::client_ip::extract_client_ip(req).into()
-}
diff --git a/crates/secrets-mcp/src/main.rs b/crates/secrets-mcp/src/main.rs
deleted file mode 100644
index de056d9..0000000
--- a/crates/secrets-mcp/src/main.rs
+++ /dev/null
@@ -1,366 +0,0 @@
-mod auth;
-mod client_ip;
-mod error;
-mod logging;
-mod oauth;
-mod rate_limit;
-mod tools;
-mod validation;
-mod web;
-
-use std::net::SocketAddr;
-
-use anyhow::{Context, Result};
-use axum::Router;
-use rmcp::transport::streamable_http_server::{
-    StreamableHttpService, session::local::LocalSessionManager,
-};
-use sqlx::PgPool;
-use tower_http::cors::{Any, CorsLayer};
-use tower_sessions::cookie::SameSite;
-use tower_sessions::session_store::ExpiredDeletion;
-use tower_sessions::{Expiry, SessionManagerLayer};
-use tower_sessions_sqlx_store_chrono::PostgresStore;
-use tracing_subscriber::EnvFilter;
-use tracing_subscriber::fmt::time::FormatTime;
-
-use secrets_core::config::resolve_db_config;
-use secrets_core::db::{create_pool, migrate};
-use secrets_core::service::delete::purge_expired_deleted_entries;
-
-use crate::oauth::OAuthConfig;
-use crate::tools::SecretsService;
-
-/// Shared application state injected into web routes and middleware.
-#[derive(Clone)]
-pub struct AppState {
-    pub pool: PgPool,
-    pub google_config: Option,
-    pub base_url: String,
-    pub http_client: reqwest::Client,
-}
-
-fn load_env_var(name: &str) -> Option {
-    std::env::var(name).ok().filter(|s| !s.is_empty())
-}
-
-/// Pretty-print bind address in logs (`127.0.0.1` → `localhost`); actual socket bind unchanged.
-fn listen_addr_log_display(bind_addr: &str) -> String {
-    bind_addr
-        .strip_prefix("127.0.0.1:")
-        .map(|port| format!("localhost:{port}"))
-        .unwrap_or_else(|| bind_addr.to_string())
-}
-
-fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option {
-    let client_id = load_env_var(&format!("{}_CLIENT_ID", prefix))?;
-    let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?;
-    Some(OAuthConfig {
-        client_id,
-        client_secret,
-        redirect_uri: format!("{}{}", base_url, path),
-    })
-}
-
-/// Log line timestamps in the process local timezone (honors `TZ` / system zone).
-#[derive(Clone, Copy, Default)]
-struct LocalRfc3339Time;
-
-impl FormatTime for LocalRfc3339Time {
-    fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
-        write!(
-            w,
-            "{}",
-            chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false)
-        )
-    }
-}
-
-#[tokio::main]
-async fn main() -> Result<()> {
-    // Load .env if present
-    let _ = dotenvy::dotenv();
-
-    tracing_subscriber::fmt()
-        .with_timer(LocalRfc3339Time)
-        .with_env_filter(
-            EnvFilter::try_from_default_env()
-                .unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()),
-        )
-        .init();
-
-    // ── Database ──────────────────────────────────────────────────────────────
-    let db_config = resolve_db_config("")
-        .context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
-    let pool = create_pool(&db_config)
-        .await
-        .context("failed to connect to database")?;
-    migrate(&pool)
-        .await
-        .context("failed to run database migrations")?;
-    tracing::info!("Database connected and migrated");
-
-    // ── Configuration ─────────────────────────────────────────────────────────
-    let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string());
-    let bind_addr =
-        load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "127.0.0.1:9315".to_string());
-
-    // ── OAuth providers ───────────────────────────────────────────────────────
-    let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback");
-
-    if google_config.is_none() {
-        tracing::warn!(
-            "No OAuth providers configured. Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable login."
-        );
-    }
-
-    // ── Session store (PostgreSQL-backed) ─────────────────────────────────────
-    let session_store = PostgresStore::new(pool.clone());
-    session_store
-        .migrate()
-        .await
-        .context("failed to run session table migration")?;
-    // Prune expired rows every hour; task is aborted when the server shuts down.
-    let session_cleanup = tokio::spawn(
-        session_store
-            .clone()
-            .continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
-    );
-    // Strict would drop the session cookie on redirect from Google → our origin (cross-site nav).
-    let session_layer = SessionManagerLayer::new(session_store)
-        .with_secure(base_url.starts_with("https://"))
-        .with_same_site(SameSite::Lax)
-        .with_expiry(Expiry::OnInactivity(time::Duration::days(14)));
-
-    // ── App state ─────────────────────────────────────────────────────────────
-    let app_state = AppState {
-        pool: pool.clone(),
-        google_config,
-        base_url: base_url.clone(),
-        http_client: reqwest::Client::builder()
-            .timeout(std::time::Duration::from_secs(15))
-            .build()
-            .context("failed to build HTTP client")?,
-    };
-
-    // ── MCP service ───────────────────────────────────────────────────────────
-    let pool_for_mcp = pool.clone();
-
-    let mcp_service = StreamableHttpService::new(
-        move || {
-            let p = pool_for_mcp.clone();
-            Ok(SecretsService::new(p))
-        },
-        LocalSessionManager::default().into(),
-        Default::default(),
-    );
-
-    // ── Router ────────────────────────────────────────────────────────────────
-    // CORS: restrict origins in production, allow all in development
-    let is_production = matches!(
-        load_env_var("SECRETS_ENV")
-            .as_deref()
-            .map(|s| s.to_ascii_lowercase())
-            .as_deref(),
-        Some("prod" | "production")
-    );
-
-    let cors = build_cors_layer(&base_url, is_production);
-
-    // Rate limiting
-    let rate_limit_state = rate_limit::RateLimitState::new();
-    let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
-    let recycle_bin_cleanup = tokio::spawn(start_recycle_bin_cleanup_task(pool.clone()));
-
-    let router = Router::new()
-        .merge(web::web_router())
-        .nest_service("/mcp", mcp_service)
-        .layer(axum::middleware::from_fn(
-            logging::request_logging_middleware,
-        ))
-        .layer(axum::middleware::from_fn_with_state(
-            pool,
-            auth::bearer_auth_middleware,
-        ))
-        .layer(axum::middleware::from_fn_with_state(
-            rate_limit_state.clone(),
-            rate_limit::rate_limit_middleware,
-        ))
-        .layer(session_layer)
-        .layer(cors)
-        .layer(tower_http::limit::RequestBodyLimitLayer::new(
-            10 * 1024 * 1024,
-        ))
-        .with_state(app_state);
-
-    // ── Start server ──────────────────────────────────────────────────────────
-    let listener = tokio::net::TcpListener::bind(&bind_addr)
-        .await
-        .with_context(|| format!("failed to bind to {}", bind_addr))?;
-
-    tracing::info!(
-        "Secrets MCP Server listening on http://{}",
-        listen_addr_log_display(&bind_addr)
-    );
-    tracing::info!("MCP endpoint: {}/mcp", base_url);
-
-    axum::serve(
-        listener,
-        router.into_make_service_with_connect_info::(),
-    )
-    .with_graceful_shutdown(shutdown_signal())
-    .await
-    .context("server error")?;
-
-    session_cleanup.abort();
-    rate_limit_cleanup.abort();
-    recycle_bin_cleanup.abort();
-    Ok(())
-}
-
-async fn start_recycle_bin_cleanup_task(pool: PgPool) {
-    let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
-    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
-
-    loop {
-        interval.tick().await;
-        match purge_expired_deleted_entries(&pool).await {
-            Ok(count) if count > 0 => {
-                tracing::info!(purged_count = count, "purged expired recycle bin entries");
-            }
-            Ok(_) => {}
-            Err(error) => {
-                tracing::warn!(error = %error, "failed to purge expired recycle bin entries");
-            }
-        }
-    }
-}
-
-async fn shutdown_signal() {
-    let ctrl_c = tokio::signal::ctrl_c();
-
-    #[cfg(unix)]
-    let terminate = async {
-        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
-            .expect("failed to install SIGTERM handler")
-            .recv()
-            .await;
-    };
-
-    #[cfg(not(unix))]
-    let terminate = std::future::pending::<()>();
-
-    tokio::select! {
-        _ = ctrl_c => {},
-        _ = terminate => {},
-    }
-
-    tracing::info!("Shutting down gracefully...");
-}
-
-/// Production CORS allowed headers.
-///
-/// When adding a new custom header to the MCP or Web API, this list must be
-/// updated accordingly — otherwise browsers will block the request during
-/// the CORS preflight check.
-fn production_allowed_headers() -> [axum::http::HeaderName; 5] {
-    [
-        axum::http::header::AUTHORIZATION,
-        axum::http::header::CONTENT_TYPE,
-        axum::http::HeaderName::from_static("x-encryption-key"),
-        axum::http::HeaderName::from_static("mcp-session-id"),
-        axum::http::HeaderName::from_static("x-mcp-session"),
-    ]
-}
-
-/// Production CORS allowed methods.
-///
-/// Keep this list explicit because tower-http rejects
-/// `allow_credentials(true)` together with `allow_methods(Any)`.
-fn production_allowed_methods() -> [axum::http::Method; 5] {
-    [
-        axum::http::Method::GET,
-        axum::http::Method::POST,
-        axum::http::Method::PATCH,
-        axum::http::Method::DELETE,
-        axum::http::Method::OPTIONS,
-    ]
-}
-
-/// Build the CORS layer for the application.
-///
-/// In production mode the origin is restricted to the BASE_URL origin
-/// (scheme://host:port, path stripped) and credentials are allowed.
-/// `allow_headers` and `allow_methods` use explicit whitelists to avoid the
-/// tower-http restriction on `allow_credentials(true)` + wildcards.
-///
-/// In development mode all origins, methods and headers are allowed.
-fn build_cors_layer(base_url: &str, is_production: bool) -> CorsLayer {
-    if is_production {
-        let allowed_origin = if let Ok(parsed) = base_url.parse::() {
-            let origin = parsed.origin().ascii_serialization();
-            origin
-                .parse::()
-                .unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin))
-        } else {
-            base_url
-                .parse::()
-                .unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url))
-        };
-        CorsLayer::new()
-            .allow_origin(allowed_origin)
-            .allow_methods(production_allowed_methods())
-            .allow_headers(production_allowed_headers())
-            .allow_credentials(true)
-    } else {
-        CorsLayer::new()
-            .allow_origin(Any)
-            .allow_methods(Any)
-            .allow_headers(Any)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn production_cors_does_not_panic() {
-        let layer = build_cors_layer("https://secrets.example.com/app", true);
-        let _ = layer;
-    }
-
-    #[test]
-    fn production_cors_headers_include_all_required() {
-        let headers = production_allowed_headers();
-        let names: Vec<&str> = headers.iter().map(|h| h.as_str()).collect();
-        assert!(names.contains(&"authorization"));
-        assert!(names.contains(&"content-type"));
-        assert!(names.contains(&"x-encryption-key"));
-        assert!(names.contains(&"mcp-session-id"));
-        assert!(names.contains(&"x-mcp-session"));
-    }
-
-    #[test]
-    fn production_cors_methods_include_all_required() {
-        let methods = production_allowed_methods();
-        assert!(methods.contains(&axum::http::Method::GET));
-        assert!(methods.contains(&axum::http::Method::POST));
-        assert!(methods.contains(&axum::http::Method::PATCH));
-        assert!(methods.contains(&axum::http::Method::DELETE));
-        assert!(methods.contains(&axum::http::Method::OPTIONS));
-    }
-
-    #[test]
-    fn production_cors_normalizes_base_url_with_path() {
-        let url = url::Url::parse("https://secrets.example.com/secrets/app").unwrap();
-        let origin = url.origin().ascii_serialization();
-        assert_eq!(origin, "https://secrets.example.com");
-    }
-
-    #[test]
-    fn development_cors_allows_everything() {
-        let layer = build_cors_layer("http://localhost:9315", false);
-        let _ = layer;
-    }
-}
diff --git a/crates/secrets-mcp/src/oauth/google.rs b/crates/secrets-mcp/src/oauth/google.rs
deleted file mode 100644
index 136e607..0000000
--- a/crates/secrets-mcp/src/oauth/google.rs
+++ /dev/null
@@ -1,116 +0,0 @@
-use std::time::Duration;
-
-use anyhow::{Context, Result};
-use serde::Deserialize;
-
-use super::{OAuthConfig, OAuthUserInfo};
-
-/// OAuth token / userinfo calls can be slow on poor routes; keep above client default if needed.
-const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(45);
-
-#[derive(Deserialize)]
-struct TokenResponse {
-    access_token: String,
-    #[allow(dead_code)]
-    token_type: String,
-    #[allow(dead_code)]
-    id_token: Option,
-}
-
-#[derive(Deserialize)]
-struct UserInfo {
-    sub: String,
-    email: Option,
-    name: Option,
-    picture: Option,
-}
-
-fn map_reqwest_send_err(e: reqwest::Error) -> anyhow::Error {
-    if e.is_timeout() {
-        anyhow::anyhow!(
-            "timeout reaching Google OAuth ({}s); ensure outbound HTTPS to oauth2.googleapis.com works (firewall/proxy/VPN if Google is unreachable)",
-            OAUTH_HTTP_TIMEOUT.as_secs()
-        )
-    } else if e.is_connect() {
-        anyhow::anyhow!("connection error to Google OAuth: {e}")
-    } else {
-        anyhow::Error::new(e)
-    }
-}
-
-/// Exchange authorization code for tokens and fetch user profile.
-pub async fn exchange_code(
-    client: &reqwest::Client,
-    config: &OAuthConfig,
-    code: &str,
-) -> Result {
-    let token_http = client
-        .post("https://oauth2.googleapis.com/token")
-        .timeout(OAUTH_HTTP_TIMEOUT)
-        .form(&[
-            ("code", code),
-            ("client_id", &config.client_id),
-            ("client_secret", &config.client_secret),
-            ("redirect_uri", &config.redirect_uri),
-            ("grant_type", "authorization_code"),
-        ])
-        .send()
-        .await
-        .map_err(map_reqwest_send_err)
-        .context("Google token HTTP request failed")?;
-
-    let status = token_http.status();
-    let body_bytes = token_http
-        .bytes()
-        .await
-        .context("read Google token response body")?;
-
-    if !status.is_success() {
-        let body_lossy = String::from_utf8_lossy(&body_bytes);
-        tracing::warn!(%status, body = %body_lossy, "Google token endpoint error");
-        anyhow::bail!(
-            "Google token error {}: {}",
-            status,
-            body_lossy.chars().take(512).collect::()
-        );
-    }
-
-    let token_resp: TokenResponse =
-        serde_json::from_slice(&body_bytes).context("failed to parse Google token JSON")?;
-
-    let user_http = client
-        .get("https://openidconnect.googleapis.com/v1/userinfo")
-        .timeout(OAUTH_HTTP_TIMEOUT)
-        .bearer_auth(&token_resp.access_token)
-        .send()
-        .await
-        .map_err(map_reqwest_send_err)
-        .context("Google userinfo HTTP request failed")?;
-
-    let status = user_http.status();
-    let body_bytes = user_http
-        .bytes()
-        .await
-        .context("read Google userinfo body")?;
-
-    if !status.is_success() {
-        let body_lossy = String::from_utf8_lossy(&body_bytes);
-        tracing::warn!(%status, body = %body_lossy, "Google userinfo endpoint error");
-        anyhow::bail!(
-            "Google userinfo error {}: {}",
-            status,
-            body_lossy.chars().take(512).collect::()
-        );
-    }
-
-    let user: UserInfo =
-        serde_json::from_slice(&body_bytes).context("failed to parse Google userinfo JSON")?;
-
-    Ok(OAuthUserInfo {
-        provider: "google".to_string(),
-        provider_id: user.sub,
-        email: user.email,
-        name: user.name,
-        avatar_url: user.picture,
-    })
-}
diff --git a/crates/secrets-mcp/src/oauth/mod.rs b/crates/secrets-mcp/src/oauth/mod.rs
deleted file mode 100644
index 59cf941..0000000
--- a/crates/secrets-mcp/src/oauth/mod.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-pub mod google;
-pub mod wechat; // not yet implemented — placeholder for future WeChat integration
-
-use serde::{Deserialize, Serialize};
-
-/// Normalized OAuth user profile from any provider.
-#[derive(Debug, Clone)]
-pub struct OAuthUserInfo {
-    pub provider: String,
-    pub provider_id: String,
-    pub email: Option,
-    pub name: Option,
-    pub avatar_url: Option,
-}
-
-/// OAuth provider configuration.
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub struct OAuthConfig {
-    pub client_id: String,
-    pub client_secret: String,
-    pub redirect_uri: String,
-}
-
-/// Build the Google authorization URL.
-pub fn google_auth_url(config: &OAuthConfig, state: &str) -> String {
-    format!(
-        "https://accounts.google.com/o/oauth2/v2/auth\
-         ?client_id={}\
-         &redirect_uri={}\
-         &response_type=code\
-         &scope=openid%20email%20profile\
-         &state={}\
-         &access_type=offline",
-        urlencoding::encode(&config.client_id),
-        urlencoding::encode(&config.redirect_uri),
-        urlencoding::encode(state),
-    )
-}
-
-pub fn random_state() -> String {
-    use rand::RngExt;
-    let mut bytes = [0u8; 16];
-    rand::rng().fill(&mut bytes);
-    secrets_core::crypto::hex::encode_hex(&bytes)
-}
diff --git a/crates/secrets-mcp/src/oauth/wechat.rs b/crates/secrets-mcp/src/oauth/wechat.rs
deleted file mode 100644
index 7ed4ebc..0000000
--- a/crates/secrets-mcp/src/oauth/wechat.rs
+++ /dev/null
@@ -1,18 +0,0 @@
-use super::{OAuthConfig, OAuthUserInfo};
-/// WeChat OAuth — not yet implemented.
-///
-/// This module is a placeholder for future WeChat Open Platform integration.
-/// When ready, implement `exchange_code` following the non-standard WeChat OAuth 2.0 flow:
-/// - Token exchange uses a GET request (not POST)
-/// - Preferred user identifier is `unionid` (cross-app), falling back to `openid`
-/// - Docs: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
-use anyhow::{Result, bail};
-
-#[allow(dead_code)] // Placeholder — implement when WeChat login is needed.
-pub async fn exchange_code(
-    _client: &reqwest::Client,
-    _config: &OAuthConfig,
-    _code: &str,
-) -> Result {
-    bail!("WeChat login is not yet implemented")
-}
diff --git a/crates/secrets-mcp/src/rate_limit.rs b/crates/secrets-mcp/src/rate_limit.rs
deleted file mode 100644
index 0e00312..0000000
--- a/crates/secrets-mcp/src/rate_limit.rs
+++ /dev/null
@@ -1,160 +0,0 @@
-use std::num::NonZeroU32;
-use std::sync::Arc;
-use std::time::Duration;
-
-use axum::{
-    extract::{Request, State},
-    http::{HeaderMap, HeaderValue, StatusCode},
-    middleware::Next,
-    response::{IntoResponse, Response},
-};
-use governor::{
-    Quota, RateLimiter,
-    clock::{Clock, DefaultClock},
-    state::{InMemoryState, NotKeyed, keyed::DashMapStateStore},
-};
-use serde_json::json;
-
-use crate::client_ip;
-
-/// Per-IP rate limiter (keyed by client IP string)
-type IpRateLimiter = RateLimiter, DefaultClock>;
-
-/// Global rate limiter (not keyed)
-type GlobalRateLimiter = RateLimiter;
-
-/// Parse a u32 env value into NonZeroU32, logging a warning and falling back
-/// to the default if the value is zero.
-fn nz_or_log(value: u32, default: u32, name: &str) -> NonZeroU32 {
-    NonZeroU32::new(value).unwrap_or_else(|| {
-        tracing::warn!(
-            configured = value,
-            default,
-            "{name} must be non-zero, using default"
-        );
-        NonZeroU32::new(default).unwrap()
-    })
-}
-
-#[derive(Clone)]
-pub struct RateLimitState {
-    pub ip_limiter: Arc,
-    pub global_limiter: Arc,
-}
-
-impl RateLimitState {
-    /// Create a new RateLimitState with default limits.
-    ///
-    /// Default limits (can be overridden via environment variables):
-    /// - Global: 100 req/s, burst 200
-    /// - Per-IP: 20 req/s, burst 40
-    pub fn new() -> Self {
-        let global_rate = std::env::var("RATE_LIMIT_GLOBAL_PER_SECOND")
-            .ok()
-            .and_then(|v| v.parse::().ok())
-            .unwrap_or(100);
-
-        let global_burst = std::env::var("RATE_LIMIT_GLOBAL_BURST")
-            .ok()
-            .and_then(|v| v.parse::().ok())
-            .unwrap_or(200);
-
-        let ip_rate = std::env::var("RATE_LIMIT_IP_PER_SECOND")
-            .ok()
-            .and_then(|v| v.parse::().ok())
-            .unwrap_or(20);
-
-        let ip_burst = std::env::var("RATE_LIMIT_IP_BURST")
-            .ok()
-            .and_then(|v| v.parse::().ok())
-            .unwrap_or(40);
-
-        let global_rate_nz = nz_or_log(global_rate, 100, "RATE_LIMIT_GLOBAL_PER_SECOND");
-        let global_burst_nz = nz_or_log(global_burst, 200, "RATE_LIMIT_GLOBAL_BURST");
-        let ip_rate_nz = nz_or_log(ip_rate, 20, "RATE_LIMIT_IP_PER_SECOND");
-        let ip_burst_nz = nz_or_log(ip_burst, 40, "RATE_LIMIT_IP_BURST");
-
-        let global_quota = Quota::per_second(global_rate_nz).allow_burst(global_burst_nz);
-        let ip_quota = Quota::per_second(ip_rate_nz).allow_burst(ip_burst_nz);
-
-        tracing::info!(
-            global_rate = global_rate_nz.get(),
-            global_burst = global_burst_nz.get(),
-            ip_rate = ip_rate_nz.get(),
-            ip_burst = ip_burst_nz.get(),
-            "rate limiter initialized"
-        );
-
-        Self {
-            global_limiter: Arc::new(RateLimiter::direct(global_quota)),
-            ip_limiter: Arc::new(RateLimiter::dashmap(ip_quota)),
-        }
-    }
-}
-
-/// Rate limiting middleware function.
-///
-/// Checks both global and per-IP rate limits before allowing the request through.
-/// Returns 429 Too Many Requests if either limit is exceeded.
-pub async fn rate_limit_middleware(
-    State(rl): State,
-    req: Request,
-    next: Next,
-) -> Result {
-    // Check global rate limit first
-    if let Err(negative) = rl.global_limiter.check() {
-        let retry_after = negative.wait_time_from(DefaultClock::default().now());
-        tracing::warn!(
-            retry_after_secs = retry_after.as_secs(),
-            "global rate limit exceeded"
-        );
-        return Err(too_many_requests_response(Some(retry_after)));
-    }
-
-    // Check per-IP rate limit
-    let key = client_ip::extract_client_ip(&req);
-    if let Err(negative) = rl.ip_limiter.check_key(&key) {
-        let retry_after = negative.wait_time_from(DefaultClock::default().now());
-        tracing::warn!(
-            client_ip = %key,
-            retry_after_secs = retry_after.as_secs(),
-            "per-IP rate limit exceeded"
-        );
-        return Err(too_many_requests_response(Some(retry_after)));
-    }
-
-    Ok(next.run(req).await)
-}
-
-/// Start a background task to clean up expired rate limiter entries.
-///
-/// This should be called once during application startup.
-/// The task runs every 60 seconds and will be aborted on shutdown.
-pub fn spawn_cleanup_task(ip_limiter: Arc) -> tokio::task::JoinHandle<()> {
-    tokio::spawn(async move {
-        let mut interval = tokio::time::interval(Duration::from_secs(60));
-        loop {
-            interval.tick().await;
-            ip_limiter.retain_recent();
-        }
-    })
-}
-
-/// Create a 429 Too Many Requests response.
-fn too_many_requests_response(retry_after: Option) -> Response {
-    let mut headers = HeaderMap::new();
-    headers.insert("Content-Type", HeaderValue::from_static("application/json"));
-
-    if let Some(duration) = retry_after {
-        let secs = duration.as_secs().max(1);
-        if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
-            headers.insert("Retry-After", value);
-        }
-    }
-
-    let body = json!({
-        "error": "Too many requests, please try again later"
-    });
-
-    (StatusCode::TOO_MANY_REQUESTS, headers, body.to_string()).into_response()
-}
diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs
deleted file mode 100644
index b8c8327..0000000
--- a/crates/secrets-mcp/src/tools.rs
+++ /dev/null
@@ -1,1851 +0,0 @@
-use std::time::Instant;
-
-use anyhow::Result;
-use rmcp::{
-    RoleServer, ServerHandler,
-    handler::server::wrapper::Parameters,
-    model::{
-        CallToolResult, Content, Implementation, InitializeResult, ProtocolVersion,
-        ServerCapabilities,
-    },
-    service::RequestContext,
-    tool, tool_handler, tool_router,
-};
-use schemars::JsonSchema;
-use serde::{Deserialize, Deserializer, de};
-use serde_json::{Map, Value};
-use sqlx::PgPool;
-use uuid::Uuid;
-
-use crate::validation;
-
-// ── Serde helpers for numeric parameters that may arrive as strings ──────────
-
-mod deser {
-    use super::*;
-
-    /// Deserialize a value that may come as a JSON number or a JSON string.
-    pub fn option_u32_from_string<'de, D>(deserializer: D) -> Result, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum NumOrStr {
-            Num(u32),
-            Str(String),
-        }
-
-        match Option::::deserialize(deserializer)? {
-            None => Ok(None),
-            Some(NumOrStr::Num(n)) => Ok(Some(n)),
-            Some(NumOrStr::Str(s)) => {
-                if s.is_empty() {
-                    return Ok(None);
-                }
-                s.parse::().map(Some).map_err(de::Error::custom)
-            }
-        }
-    }
-
-    /// Deserialize an i64 that may come as a JSON number or a JSON string.
-    pub fn option_i64_from_string<'de, D>(deserializer: D) -> Result, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum NumOrStr {
-            Num(i64),
-            Str(String),
-        }
-
-        match Option::::deserialize(deserializer)? {
-            None => Ok(None),
-            Some(NumOrStr::Num(n)) => Ok(Some(n)),
-            Some(NumOrStr::Str(s)) => {
-                if s.is_empty() {
-                    return Ok(None);
-                }
-                s.parse::().map(Some).map_err(de::Error::custom)
-            }
-        }
-    }
-
-    /// Deserialize a bool that may come as a JSON bool or a JSON string ("true"/"false").
-    pub fn option_bool_from_string<'de, D>(deserializer: D) -> Result, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum BoolOrStr {
-            Bool(bool),
-            Str(String),
-        }
-
-        match Option::::deserialize(deserializer)? {
-            None => Ok(None),
-            Some(BoolOrStr::Bool(b)) => Ok(Some(b)),
-            Some(BoolOrStr::Str(s)) => {
-                if s.is_empty() {
-                    return Ok(None);
-                }
-                s.parse::().map(Some).map_err(de::Error::custom)
-            }
-        }
-    }
-
-    /// Deserialize a Vec that may come as a JSON array or a JSON string containing an array.
-    pub fn option_vec_string_from_string<'de, D>(
-        deserializer: D,
-    ) -> Result>, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum VecOrStr {
-            Vec(Vec),
-            Str(String),
-        }
-
-        match Option::::deserialize(deserializer)? {
-            None => Ok(None),
-            Some(VecOrStr::Vec(v)) => Ok(Some(v)),
-            Some(VecOrStr::Str(s)) => {
-                if s.is_empty() {
-                    return Ok(None);
-                }
-                serde_json::from_str(&s)
-                    .map(Some)
-                    .map_err(|e| {
-                        de::Error::custom(format!(
-                            "invalid string value for array field: expected a JSON array, e.g. '[\"a\",\"b\"]': {e}"
-                        ))
-                    })
-            }
-        }
-    }
-
-    /// Deserialize a Map that may come as a JSON object or a JSON string containing an object.
-    pub fn option_map_from_string<'de, D>(
-        deserializer: D,
-    ) -> Result>, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum MapOrStr {
-            Map(Map),
-            Str(String),
-        }
-
-        match Option::::deserialize(deserializer)? {
-            None => Ok(None),
-            Some(MapOrStr::Map(m)) => Ok(Some(m)),
-            Some(MapOrStr::Str(s)) => {
-                if s.is_empty() {
-                    return Ok(None);
-                }
-                serde_json::from_str(&s)
-                    .map(Some)
-                    .map_err(|e| {
-                        de::Error::custom(format!(
-                            "invalid string value for object field: expected a JSON object, e.g. '{{\"key\":\"value\"}}': {e}"
-                        ))
-                    })
-            }
-        }
-    }
-}
-
-use secrets_core::models::ExportFormat;
-use secrets_core::service::{
-    add::{AddParams, run as svc_add},
-    delete::{DeleteParams, run as svc_delete},
-    export::{ExportParams, export as svc_export},
-    get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
-    history::run as svc_history,
-    relations::{add_parent_relation, get_relations_for_entries, remove_parent_relation},
-    rollback::run as svc_rollback,
-    search::{SearchParams, resolve_entry, resolve_entry_by_id, run as svc_search},
-    update::{UpdateParams, run as svc_update},
-};
-
-use crate::auth::AuthUser;
-use crate::error;
-
-// ── MCP client-facing errors (no internal details) ───────────────────────────
-
-fn mcp_err_missing_http_parts() -> rmcp::ErrorData {
-    rmcp::ErrorData::internal_error("Invalid MCP request context.", None)
-}
-
-fn mcp_err_internal_logged(
-    tool: &'static str,
-    user_id: Option,
-    err: impl std::fmt::Display,
-) -> rmcp::ErrorData {
-    tracing::warn!(tool, ?user_id, error = %err, "tool call failed");
-    rmcp::ErrorData::internal_error(
-        "Request failed due to a server error. Check service logs if you need details.",
-        None,
-    )
-}
-
-fn mcp_err_from_anyhow(
-    tool: &'static str,
-    user_id: Option,
-    err: anyhow::Error,
-) -> rmcp::ErrorData {
-    if let Some(app_err) = err.downcast_ref::() {
-        return error::app_error_to_mcp(app_err);
-    }
-    mcp_err_internal_logged(tool, user_id, err)
-}
-
-fn mcp_err_invalid_encryption_key_logged(err: impl std::fmt::Display) -> rmcp::ErrorData {
-    tracing::warn!(error = %err, "invalid X-Encryption-Key");
-    rmcp::ErrorData::invalid_request(
-        "Invalid X-Encryption-Key: must be exactly 64 hexadecimal characters (32-byte key).",
-        None,
-    )
-}
-
-// ── Shared state ──────────────────────────────────────────────────────────────
-
-#[derive(Clone)]
-pub struct SecretsService {
-    pub pool: PgPool,
-    pub tool_router: rmcp::handler::server::router::tool::ToolRouter,
-}
-
-impl SecretsService {
-    pub fn new(pool: PgPool) -> Self {
-        Self {
-            pool,
-            tool_router: Self::tool_router(),
-        }
-    }
-
-    /// Get the authenticated user_id (returns error if not authenticated).
-    fn require_user_id(ctx: &RequestContext) -> Result {
-        let parts = ctx
-            .extensions
-            .get::()
-            .ok_or_else(mcp_err_missing_http_parts)?;
-        parts
-            .extensions
-            .get::()
-            .map(|a| a.user_id)
-            .ok_or_else(|| rmcp::ErrorData::invalid_request("Unauthorized: API key required", None))
-    }
-
-    /// Extract the 32-byte encryption key from the X-Encryption-Key request header.
-    /// The header value must be 64 lowercase hex characters (PBKDF2-derived key).
-    fn extract_enc_key(ctx: &RequestContext) -> Result<[u8; 32], rmcp::ErrorData> {
-        let parts = ctx
-            .extensions
-            .get::()
-            .ok_or_else(mcp_err_missing_http_parts)?;
-        let hex_str = parts
-            .headers
-            .get("x-encryption-key")
-            .ok_or_else(|| {
-                rmcp::ErrorData::invalid_request(
-                    "Missing X-Encryption-Key header. \
-                     Set this to your 64-char hex encryption key derived from your passphrase.",
-                    None,
-                )
-            })?
-            .to_str()
-            .map_err(|_| {
-                rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None)
-            })?;
-        let trimmed = hex_str.trim();
-        // Debug-level fingerprint: helps diagnose header forwarding issues
-        // (e.g. Cursor Chat MCP truncating or transforming the key value)
-        // without revealing the full secret.
-        tracing::debug!(
-            raw_len = hex_str.len(),
-            trimmed_len = trimmed.len(),
-            key_prefix = trimmed.get(..8).unwrap_or(trimmed),
-            key_suffix = trimmed
-                .get(trimmed.len().saturating_sub(8)..)
-                .unwrap_or(trimmed),
-            "X-Encryption-Key received",
-        );
-        if trimmed.len() != 64 {
-            tracing::warn!(
-                got_len = trimmed.len(),
-                "X-Encryption-Key has wrong length after trim"
-            );
-            return Err(rmcp::ErrorData::invalid_request(
-                format!(
-                    "X-Encryption-Key must be exactly 64 hex characters (32-byte key), got {} characters.",
-                    trimmed.len()
-                ),
-                None,
-            ));
-        }
-        if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
-            tracing::warn!("X-Encryption-Key contains non-hexadecimal characters");
-            return Err(rmcp::ErrorData::invalid_request(
-                "X-Encryption-Key contains non-hexadecimal characters.",
-                None,
-            ));
-        }
-        secrets_core::crypto::extract_key_from_hex(hex_str)
-            .map_err(mcp_err_invalid_encryption_key_logged)
-    }
-
-    /// Extract the encryption key, preferring an explicit argument value over
-    /// the X-Encryption-Key HTTP header.
-    ///
-    /// `arg_key` is the optional `encryption_key` field from the tool's input
-    /// struct. When present, it is used directly and the header is ignored.
-    /// This allows MCP clients that cannot reliably forward custom HTTP headers
-    /// (e.g. Cursor Chat) to pass the key as a normal tool argument.
-    fn extract_enc_key_or_arg(
-        ctx: &RequestContext,
-        arg_key: Option<&str>,
-    ) -> Result<[u8; 32], rmcp::ErrorData> {
-        if let Some(hex_str) = arg_key {
-            let trimmed = hex_str.trim();
-            tracing::debug!(
-                source = "argument",
-                raw_len = hex_str.len(),
-                trimmed_len = trimmed.len(),
-                key_prefix = trimmed.get(..8).unwrap_or(trimmed),
-                key_suffix = trimmed
-                    .get(trimmed.len().saturating_sub(8)..)
-                    .unwrap_or(trimmed),
-                "X-Encryption-Key received",
-            );
-            if trimmed.len() != 64 {
-                return Err(rmcp::ErrorData::invalid_request(
-                    format!(
-                        "encryption_key must be exactly 64 hex characters (32-byte key), got {}.",
-                        trimmed.len()
-                    ),
-                    None,
-                ));
-            }
-            if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
-                return Err(rmcp::ErrorData::invalid_request(
-                    "encryption_key contains non-hexadecimal characters.",
-                    None,
-                ));
-            }
-            return secrets_core::crypto::extract_key_from_hex(trimmed)
-                .map_err(mcp_err_invalid_encryption_key_logged);
-        }
-        Self::extract_enc_key(ctx)
-    }
-
-    /// Require both user_id and encryption key, preferring an explicit argument
-    /// value over the X-Encryption-Key header.
-    fn require_user_and_key_or_arg(
-        ctx: &RequestContext,
-        arg_key: Option<&str>,
-    ) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
-        let user_id = Self::require_user_id(ctx)?;
-        let key = Self::extract_enc_key_or_arg(ctx, arg_key)?;
-        Ok((user_id, key))
-    }
-}
-
-// ── Tool parameter types ──────────────────────────────────────────────────────
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct FindInput {
-    #[schemars(
-        description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
-    )]
-    query: Option,
-    #[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
-    metadata_query: Option,
-    #[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
-    folder: Option,
-    #[schemars(
-        description = "Exact type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
-    )]
-    #[serde(rename = "type")]
-    entry_type: Option,
-    #[schemars(description = "Exact name filter. For fuzzy matching use name_query instead.")]
-    name: Option,
-    #[schemars(
-        description = "Fuzzy name filter (ILIKE, case-insensitive partial match). Use this instead of 'name' when you don't know the exact name."
-    )]
-    name_query: Option,
-    #[schemars(description = "Tag filters (all must match)")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    tags: Option>,
-    #[schemars(description = "Max results (default 20)")]
-    #[serde(default, deserialize_with = "deser::option_u32_from_string")]
-    limit: Option,
-    #[schemars(description = "Offset for pagination (default 0)")]
-    #[serde(default, deserialize_with = "deser::option_u32_from_string")]
-    offset: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct SearchInput {
-    #[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
-    query: Option,
-    #[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
-    metadata_query: Option,
-    #[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
-    folder: Option,
-    #[schemars(
-        description = "Type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
-    )]
-    #[serde(rename = "type")]
-    entry_type: Option,
-    #[schemars(description = "Exact name to match. For fuzzy matching use name_query instead.")]
-    name: Option,
-    #[schemars(
-        description = "Fuzzy name filter (ILIKE, case-insensitive partial match). Use this instead of 'name' when you don't know the exact name."
-    )]
-    name_query: Option,
-    #[schemars(description = "Tag filters (all must match)")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    tags: Option>,
-    #[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")]
-    #[serde(default, deserialize_with = "deser::option_bool_from_string")]
-    summary: Option,
-    #[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
-    sort: Option,
-    #[schemars(description = "Max results (default 20)")]
-    #[serde(default, deserialize_with = "deser::option_u32_from_string")]
-    limit: Option,
-    #[schemars(description = "Pagination offset (default 0)")]
-    #[serde(default, deserialize_with = "deser::option_u32_from_string")]
-    offset: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct GetSecretInput {
-    #[schemars(description = "Entry UUID obtained from secrets_find results")]
-    id: String,
-    #[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")]
-    field: Option,
-    #[schemars(description = "Encryption key as a 64-char hex string. \
-        If provided, takes priority over the X-Encryption-Key HTTP header. \
-        Use this when the MCP client cannot reliably forward custom headers.")]
-    encryption_key: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct AddInput {
-    #[schemars(description = "Unique name for this entry")]
-    name: String,
-    #[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")]
-    folder: Option,
-    #[schemars(
-        description = "Type/category of this entry (optional, e.g. 'server', 'service', 'account', 'person', 'document'). Free-form, choose what best describes the entry."
-    )]
-    #[serde(rename = "type")]
-    entry_type: Option,
-    #[schemars(description = "Free-text notes for this entry (optional)")]
-    notes: Option,
-    #[schemars(description = "Tags for this entry")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    tags: Option>,
-    #[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    meta: Option>,
-    #[schemars(
-        description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided."
-    )]
-    #[serde(default, deserialize_with = "deser::option_map_from_string")]
-    meta_obj: Option>,
-    #[schemars(
-        description = "Secret fields as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets."
-    )]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    secrets: Option>,
-    #[schemars(
-        description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address."
-    )]
-    #[serde(default, deserialize_with = "deser::option_map_from_string")]
-    secrets_obj: Option>,
-    #[schemars(
-        description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"."
-    )]
-    #[serde(default, deserialize_with = "deser::option_map_from_string")]
-    secret_types: Option>,
-    #[schemars(
-        description = "Link existing secrets by secret name. Names must resolve uniquely under current user."
-    )]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    link_secret_names: Option>,
-    #[schemars(description = "UUIDs of parent entries to link to this entry")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    parent_ids: Option>,
-    #[schemars(description = "Encryption key as a 64-char hex string. \
-        If provided, takes priority over the X-Encryption-Key HTTP header. \
-        Use this when the MCP client cannot reliably forward custom headers.")]
-    encryption_key: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct UpdateInput {
-    #[schemars(description = "Name of the entry to update")]
-    name: String,
-    #[schemars(
-        description = "Folder for disambiguation when multiple entries share the same name (optional)"
-    )]
-    folder: Option,
-    #[schemars(
-        description = "Entry UUID (from secrets_find). If provided, name/folder are used for disambiguation only."
-    )]
-    id: Option,
-    #[schemars(description = "Update the notes field")]
-    notes: Option,
-    #[schemars(description = "Tags to add")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    add_tags: Option>,
-    #[schemars(description = "Tags to remove")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    remove_tags: Option>,
-    #[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    meta: Option>,
-    #[schemars(
-        description = "Metadata fields to update/add as a JSON object {\"key\": value}. Merged with 'meta' if both provided."
-    )]
-    #[serde(default, deserialize_with = "deser::option_map_from_string")]
-    meta_obj: Option>,
-    #[schemars(description = "Metadata field keys to remove")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    remove_meta: Option>,
-    #[schemars(
-        description = "Secret fields to update/add as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets."
-    )]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    secrets: Option>,
-    #[schemars(
-        description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address."
-    )]
-    #[serde(default, deserialize_with = "deser::option_map_from_string")]
-    secrets_obj: Option>,
-    #[schemars(
-        description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"."
-    )]
-    #[serde(default, deserialize_with = "deser::option_map_from_string")]
-    secret_types: Option>,
-    #[schemars(description = "Secret field keys to remove")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    remove_secrets: Option>,
-    #[schemars(
-        description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user."
-    )]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    link_secret_names: Option>,
-    #[schemars(
-        description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted."
-    )]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    unlink_secret_names: Option>,
-    #[schemars(description = "UUIDs of parent entries to link")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    add_parent_ids: Option>,
-    #[schemars(description = "UUIDs of parent entries to unlink")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    remove_parent_ids: Option>,
-    #[schemars(description = "Encryption key as a 64-char hex string. \
-        If provided, takes priority over the X-Encryption-Key HTTP header. \
-        Use this when the MCP client cannot reliably forward custom headers.")]
-    encryption_key: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct DeleteInput {
-    #[schemars(
-        description = "Entry UUID (from secrets_find). If provided, deletes this specific entry \
-        regardless of name/folder."
-    )]
-    id: Option,
-    #[schemars(description = "Name of the entry to delete (single delete). \
-        Omit to bulk delete by folder/type filters.")]
-    name: Option,
-    #[schemars(description = "Folder filter for bulk delete")]
-    folder: Option,
-    #[schemars(description = "Type filter for bulk delete")]
-    #[serde(rename = "type")]
-    entry_type: Option,
-    #[schemars(description = "Preview deletions without writing")]
-    #[serde(default, deserialize_with = "deser::option_bool_from_string")]
-    dry_run: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct HistoryInput {
-    #[schemars(description = "Name of the entry")]
-    name: String,
-    #[schemars(
-        description = "Folder for disambiguation when multiple entries share the same name (optional)"
-    )]
-    folder: Option,
-    #[schemars(
-        description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
-    )]
-    id: Option,
-    #[schemars(description = "Max history entries to return (default 20)")]
-    #[serde(default, deserialize_with = "deser::option_u32_from_string")]
-    limit: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct RollbackInput {
-    #[schemars(description = "Entry UUID (from secrets_find) for an existing, non-deleted entry")]
-    id: String,
-    #[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
-    #[serde(default, deserialize_with = "deser::option_i64_from_string")]
-    to_version: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct ExportInput {
-    #[schemars(description = "Folder filter")]
-    folder: Option,
-    #[schemars(description = "Type filter")]
-    #[serde(rename = "type")]
-    entry_type: Option,
-    #[schemars(description = "Exact name filter")]
-    name: Option,
-    #[schemars(description = "Tag filters")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    tags: Option>,
-    #[schemars(description = "Fuzzy query")]
-    query: Option,
-    #[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")]
-    format: Option,
-    #[schemars(description = "Encryption key as a 64-char hex string. \
-        If provided, takes priority over the X-Encryption-Key HTTP header. \
-        Use this when the MCP client cannot reliably forward custom headers.")]
-    encryption_key: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct EnvMapInput {
-    #[schemars(description = "Folder filter")]
-    folder: Option,
-    #[schemars(description = "Type filter")]
-    #[serde(rename = "type")]
-    entry_type: Option,
-    #[schemars(description = "Exact name filter")]
-    name: Option,
-    #[schemars(description = "Tag filters")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    tags: Option>,
-    #[schemars(description = "Only include these secret fields")]
-    #[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
-    only_fields: Option>,
-    #[schemars(description = "Environment variable name prefix. \
-        Variable names are built as UPPER(prefix)_UPPER(entry_name)_UPPER(field_name), \
-        with hyphens and dots replaced by underscores. \
-        Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID \
-        (or PREFIX_ALIYUN_ACCESS_KEY_ID with prefix set).")]
-    prefix: Option,
-    #[schemars(description = "Encryption key as a 64-char hex string. \
-        If provided, takes priority over the X-Encryption-Key HTTP header. \
-        Use this when the MCP client cannot reliably forward custom headers.")]
-    encryption_key: Option,
-}
-
-#[derive(Debug, Deserialize, JsonSchema)]
-struct OverviewInput {}
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-/// Convert a JSON object map into "key=value" / "key:=json" strings for service-layer parsing.
-fn map_to_kv_strings(map: Map) -> Vec {
-    map.into_iter()
-        .map(|(k, v)| match &v {
-            Value::String(s) => format!("{}={}", k, s),
-            _ => format!("{}:={}", k, v),
-        })
-        .collect()
-}
-
-/// Check if any KV string would trigger a server-side file read.
-///
-/// `parse_kv` in secrets-core supports two file-read syntaxes:
-///   - `key=@path`  (has `=`, value starts with `@`)
-///   - `key@path`   (no `=`, split on `@`)
-///
-/// Both are legitimate for CLI usage but must be rejected in the MCP context
-/// where the server process runs remotely and the caller controls the path.
-///
-/// Note: `key:=json` is intentionally skipped here. Although the value may
-/// contain `@` characters (e.g. `config:=@/etc/passwd`), the `:=` branch in
-/// `parse_kv` treats the right-hand side as raw JSON and never performs file
-/// reads. The `@` in such cases is just data, not a file reference.
-///
-/// For entries without `=` that contain `@`, we only reject them if the `@`
-/// appears to be file-path syntax (i.e., the part after `@` starts with `/`,
-/// `~`, or `.`). This avoids false positives on values like `user@example.com`.
-fn contains_file_reference(entries: &[String]) -> Option {
-    for entry in entries {
-        // key:=json — safe, skip before checking for `=`
-        if entry.contains(":=") {
-            continue;
-        }
-        // key=@path
-        if let Some((_, value)) = entry.split_once('=') {
-            if value.starts_with('@') {
-                return Some(entry.clone());
-            }
-            continue;
-        }
-        // key@path (no `=` present)
-        // Only reject if the `@` looks like file-path syntax: the segment after
-        // `@` starts with `/`, `~`, or `.`, which are common path prefixes.
-        // Values like "user@example.com" pass through safely.
-        if let Some((_, path_part)) = entry.split_once('@') {
-            let trimmed = path_part.trim_start();
-            if trimmed.starts_with('/') || trimmed.starts_with('~') || trimmed.starts_with('.') {
-                return Some(entry.clone());
-            }
-        }
-    }
-    None
-}
-
-/// Parse a UUID string, returning an MCP error on failure.
-fn parse_uuid(s: &str) -> Result {
-    s.parse::()
-        .map_err(|_| rmcp::ErrorData::invalid_request(format!("Invalid UUID: '{}'", s), None))
-}
-
-fn parse_uuid_list(values: &[String]) -> Result, rmcp::ErrorData> {
-    values.iter().map(|value| parse_uuid(value)).collect()
-}
-
-// ── Tool implementations ──────────────────────────────────────────────────────
-
-#[tool_router]
-impl SecretsService {
-    #[tool(
-        description = "Find entries in the secrets store by folder, name, type, tags, or a \
-        fuzzy query that also searches metadata values. Requires Bearer API key. \
-        Returns 0 or more entries with id, metadata, and secret field names (not values). \
-        Use the returned id with secrets_get to decrypt secret values. \
-        Replaces secrets_search for discovery tasks.",
-        annotations(title = "Find Secrets", read_only_hint = true, idempotent_hint = true)
-    )]
-    async fn secrets_find(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let user_id = Self::require_user_id(&ctx)?;
-        tracing::info!(
-            tool = "secrets_find",
-            ?user_id,
-            folder = input.folder.as_deref(),
-            entry_type = input.entry_type.as_deref(),
-            name = input.name.as_deref(),
-            name_query = input.name_query.as_deref(),
-            query = input.query.as_deref(),
-            metadata_query = input.metadata_query.as_deref(),
-            "tool call start",
-        );
-        let tags = input.tags.unwrap_or_default();
-        let result = svc_search(
-            &self.pool,
-            SearchParams {
-                folder: input.folder.as_deref(),
-                entry_type: input.entry_type.as_deref(),
-                name: input.name.as_deref(),
-                name_query: input.name_query.as_deref(),
-                tags: &tags,
-                query: input.query.as_deref(),
-                metadata_query: input.metadata_query.as_deref(),
-                sort: "name",
-                limit: input.limit.unwrap_or(20),
-                offset: input.offset.unwrap_or(0),
-                user_id: Some(user_id),
-            },
-        )
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
-
-        let count_params = SearchParams {
-            folder: input.folder.as_deref(),
-            entry_type: input.entry_type.as_deref(),
-            name: input.name.as_deref(),
-            name_query: input.name_query.as_deref(),
-            tags: &tags,
-            query: input.query.as_deref(),
-            metadata_query: input.metadata_query.as_deref(),
-            sort: "name",
-            limit: 0,
-            offset: 0,
-            user_id: Some(user_id),
-        };
-
-        let total_count = secrets_core::service::search::count_entries(&self.pool, &count_params)
-            .await
-            .map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
-        let relation_map = get_relations_for_entries(
-            &self.pool,
-            &result
-                .entries
-                .iter()
-                .map(|entry| entry.id)
-                .collect::>(),
-            Some(user_id),
-        )
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
-
-        let entries: Vec = result
-            .entries
-            .iter()
-            .map(|e| {
-                let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
-                let schema: Vec = result
-                    .secret_schemas
-                    .get(&e.id)
-                    .map(|f| {
-                        f.iter()
-                            .map(|s| {
-                                serde_json::json!({
-                                    "id": s.id,
-                                    "name": s.name,
-                                    "type": s.secret_type,
-                                })
-                            })
-                            .collect()
-                    })
-                    .unwrap_or_default();
-                serde_json::json!({
-                    "id": e.id,
-                    "name": e.name,
-                    "folder": e.folder,
-                    "type": e.entry_type,
-                    "tags": e.tags,
-                    "metadata": e.metadata,
-                    "parents": relations.parents,
-                    "children": relations.children,
-                    "secret_fields": schema,
-                    "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
-                })
-            })
-            .collect();
-
-        let output = serde_json::json!({
-            "total_count": total_count,
-            "entries": entries,
-        });
-
-        tracing::info!(
-            tool = "secrets_find",
-            ?user_id,
-            result_count = entries.len(),
-            total_count,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string());
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Search entries in the secrets store. Requires Bearer API key. Returns \
-        entries with metadata and secret field names (not values). \
-        Prefer secrets_find for discovery; secrets_search is kept for backward compatibility.",
-        annotations(
-            title = "Search Secrets",
-            read_only_hint = true,
-            idempotent_hint = true
-        )
-    )]
-    async fn secrets_search(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let user_id = Self::require_user_id(&ctx)?;
-        tracing::info!(
-            tool = "secrets_search",
-            ?user_id,
-            folder = input.folder.as_deref(),
-            entry_type = input.entry_type.as_deref(),
-            name = input.name.as_deref(),
-            name_query = input.name_query.as_deref(),
-            query = input.query.as_deref(),
-            metadata_query = input.metadata_query.as_deref(),
-            "tool call start",
-        );
-        let tags = input.tags.unwrap_or_default();
-        let result = svc_search(
-            &self.pool,
-            SearchParams {
-                folder: input.folder.as_deref(),
-                entry_type: input.entry_type.as_deref(),
-                name: input.name.as_deref(),
-                name_query: input.name_query.as_deref(),
-                tags: &tags,
-                query: input.query.as_deref(),
-                metadata_query: input.metadata_query.as_deref(),
-                sort: input.sort.as_deref().unwrap_or("name"),
-                limit: input.limit.unwrap_or(20),
-                offset: input.offset.unwrap_or(0),
-                user_id: Some(user_id),
-            },
-        )
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
-        let relation_map = get_relations_for_entries(
-            &self.pool,
-            &result
-                .entries
-                .iter()
-                .map(|entry| entry.id)
-                .collect::>(),
-            Some(user_id),
-        )
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
-
-        let summary = input.summary.unwrap_or(false);
-        let entries: Vec = result
-            .entries
-            .iter()
-            .map(|e| {
-                let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
-                if summary {
-                    serde_json::json!({
-                        "name": e.name,
-                        "folder": e.folder,
-                        "type": e.entry_type,
-                        "tags": e.tags,
-                        "notes": e.notes,
-                        "parents": relations.parents,
-                        "children": relations.children,
-                        "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
-                    })
-                } else {
-                    let schema: Vec = result
-                        .secret_schemas
-                        .get(&e.id)
-                        .map(|f| {
-                            f.iter()
-                                .map(|s| {
-                                    serde_json::json!({
-                                        "id": s.id,
-                                        "name": s.name,
-                                        "type": s.secret_type,
-                                    })
-                                })
-                                .collect()
-                        })
-                        .unwrap_or_default();
-                    serde_json::json!({
-                        "id": e.id,
-                        "name": e.name,
-                        "folder": e.folder,
-                        "type": e.entry_type,
-                        "notes": e.notes,
-                        "tags": e.tags,
-                        "metadata": e.metadata,
-                        "parents": relations.parents,
-                        "children": relations.children,
-                        "secret_fields": schema,
-                        "version": e.version,
-                        "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
-                    })
-                }
-            })
-            .collect();
-
-        let count = entries.len();
-        tracing::info!(
-            tool = "secrets_search",
-            ?user_id,
-            result_count = count,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Get decrypted secret field values for an entry identified by its UUID \
-        (from secrets_find). Requires X-Encryption-Key header. \
-        Returns all fields, or a specific field if 'field' is provided.",
-        annotations(
-            title = "Get Secret Values",
-            read_only_hint = true,
-            idempotent_hint = true
-        )
-    )]
-    async fn secrets_get(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let (user_id, user_key) =
-            Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
-        let entry_id = parse_uuid(&input.id)?;
-        tracing::info!(
-            tool = "secrets_get",
-            id = %input.id,
-            field = input.field.as_deref(),
-            "tool call start",
-        );
-
-        if let Some(field_name) = &input.field {
-            let value =
-                get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id))
-                    .await
-                    .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
-
-            tracing::info!(
-                tool = "secrets_get",
-                id = %input.id,
-                elapsed_ms = t.elapsed().as_millis(),
-                "tool call ok",
-            );
-            let result = serde_json::json!({ field_name: value });
-            let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-            Ok(CallToolResult::success(vec![Content::text(json)]))
-        } else {
-            let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id))
-                .await
-                .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
-
-            tracing::info!(
-            tool = "secrets_get",
-            id = %entry_id,
-            field_count = secrets.len(),
-                elapsed_ms = t.elapsed().as_millis(),
-                "tool call ok",
-            );
-            let json = serde_json::to_string_pretty(&secrets).unwrap_or_default();
-            Ok(CallToolResult::success(vec![Content::text(json)]))
-        }
-    }
-
-    #[tool(
-        description = "Add or upsert an entry with metadata and encrypted secret fields. \
-        Requires X-Encryption-Key header. \
-        Meta and secret values use 'key=value', 'key=@file', or 'key:=' format, \
-        or pass a JSON object via meta_obj / secrets_obj.",
-        annotations(title = "Add Secret Entry")
-    )]
-    async fn secrets_add(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let (user_id, user_key) =
-            Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
-        tracing::info!(
-            tool = "secrets_add",
-            ?user_id,
-            name = %input.name,
-            folder = input.folder.as_deref(),
-            entry_type = input.entry_type.as_deref(),
-            "tool call start",
-        );
-
-        let tags = input.tags.unwrap_or_default();
-        let mut meta = input.meta.unwrap_or_default();
-        if let Some(obj) = input.meta_obj {
-            meta.extend(map_to_kv_strings(obj));
-        }
-        if let Some(offending) = contains_file_reference(&meta) {
-            return Err(rmcp::ErrorData::invalid_params(
-                format!("@file syntax is not allowed in MCP tools: '{}'", offending),
-                None,
-            ));
-        }
-        let mut secrets = input.secrets.unwrap_or_default();
-        if let Some(obj) = input.secrets_obj {
-            secrets.extend(map_to_kv_strings(obj));
-        }
-        if let Some(offending) = contains_file_reference(&secrets) {
-            return Err(rmcp::ErrorData::invalid_params(
-                format!("@file syntax is not allowed in MCP tools: '{}'", offending),
-                None,
-            ));
-        }
-
-        // Input length validation
-        validation::validate_input_lengths(
-            &input.name,
-            input.folder.as_deref(),
-            input.entry_type.as_deref(),
-            input.notes.as_deref(),
-        )?;
-        validation::validate_tags(&tags)?;
-        validation::validate_meta_entries(&meta)?;
-
-        let secret_types = input.secret_types.unwrap_or_default();
-        let secret_types_map: std::collections::HashMap = secret_types
-            .into_iter()
-            .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
-            .collect();
-        let link_secret_names = input.link_secret_names.unwrap_or_default();
-        let parent_ids = parse_uuid_list(&input.parent_ids.unwrap_or_default())?;
-        let folder = input.folder.as_deref().unwrap_or("");
-        let entry_type = input.entry_type.as_deref().unwrap_or("");
-        let notes = input.notes.as_deref().unwrap_or("");
-
-        let result = svc_add(
-            &self.pool,
-            AddParams {
-                name: &input.name,
-                folder,
-                entry_type,
-                notes,
-                tags: &tags,
-                meta_entries: &meta,
-                secret_entries: &secrets,
-                secret_types: &secret_types_map,
-                link_secret_names: &link_secret_names,
-                user_id: Some(user_id),
-            },
-            &user_key,
-        )
-        .await
-        .map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
-
-        for parent_id in parent_ids {
-            add_parent_relation(&self.pool, parent_id, result.entry_id, Some(user_id))
-                .await
-                .map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
-        }
-
-        tracing::info!(
-            tool = "secrets_add",
-            ?user_id,
-            name = %input.name,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \
-        Only the fields you specify are changed; everything else is preserved. \
-        Optionally pass 'id' (from secrets_find) to target the entry directly.",
-        annotations(title = "Update Secret Entry")
-    )]
-    async fn secrets_update(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let (user_id, user_key) =
-            Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
-        tracing::info!(
-            tool = "secrets_update",
-            ?user_id,
-            name = %input.name,
-            id = ?input.id,
-            "tool call start",
-        );
-
-        // When id is provided, resolve to (name, folder) via primary key to skip disambiguation.
-        let (resolved_name, resolved_folder): (String, Option) =
-            if let Some(ref id_str) = input.id {
-                let eid = parse_uuid(id_str)?;
-                let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
-                    .await
-                    .map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?;
-                (entry.name, Some(entry.folder))
-            } else {
-                (input.name.clone(), input.folder.clone())
-            };
-
-        let add_tags = input.add_tags.unwrap_or_default();
-        let remove_tags = input.remove_tags.unwrap_or_default();
-        let mut meta = input.meta.unwrap_or_default();
-        if let Some(obj) = input.meta_obj {
-            meta.extend(map_to_kv_strings(obj));
-        }
-        if let Some(offending) = contains_file_reference(&meta) {
-            return Err(rmcp::ErrorData::invalid_params(
-                format!("@file syntax is not allowed in MCP tools: '{}'", offending),
-                None,
-            ));
-        }
-        let remove_meta = input.remove_meta.unwrap_or_default();
-        let mut secrets = input.secrets.unwrap_or_default();
-        if let Some(obj) = input.secrets_obj {
-            secrets.extend(map_to_kv_strings(obj));
-        }
-        if let Some(offending) = contains_file_reference(&secrets) {
-            return Err(rmcp::ErrorData::invalid_params(
-                format!("@file syntax is not allowed in MCP tools: '{}'", offending),
-                None,
-            ));
-        }
-
-        // Input length validation
-        validation::validate_input_lengths(
-            &input.name,
-            input.folder.as_deref(),
-            None,
-            input.notes.as_deref(),
-        )?;
-        validation::validate_tags(&add_tags)?;
-        validation::validate_meta_entries(&meta)?;
-
-        let secret_types = input.secret_types.unwrap_or_default();
-        let secret_types_map: std::collections::HashMap = secret_types
-            .into_iter()
-            .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
-            .collect();
-        let remove_secrets = input.remove_secrets.unwrap_or_default();
-        let link_secret_names = input.link_secret_names.unwrap_or_default();
-        let unlink_secret_names = input.unlink_secret_names.unwrap_or_default();
-        let add_parent_ids = parse_uuid_list(&input.add_parent_ids.unwrap_or_default())?;
-        let remove_parent_ids = parse_uuid_list(&input.remove_parent_ids.unwrap_or_default())?;
-
-        let result = svc_update(
-            &self.pool,
-            UpdateParams {
-                name: &resolved_name,
-                folder: resolved_folder.as_deref(),
-                notes: input.notes.as_deref(),
-                add_tags: &add_tags,
-                remove_tags: &remove_tags,
-                meta_entries: &meta,
-                remove_meta: &remove_meta,
-                secret_entries: &secrets,
-                secret_types: &secret_types_map,
-                remove_secrets: &remove_secrets,
-                link_secret_names: &link_secret_names,
-                unlink_secret_names: &unlink_secret_names,
-                user_id: Some(user_id),
-            },
-            &user_key,
-        )
-        .await
-        .map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
-
-        let entry_id = if let Some(id_str) = input.id.as_deref() {
-            parse_uuid(id_str)?
-        } else {
-            resolve_entry(
-                &self.pool,
-                &resolved_name,
-                resolved_folder.as_deref(),
-                Some(user_id),
-            )
-            .await
-            .map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?
-            .id
-        };
-        for parent_id in add_parent_ids {
-            add_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
-                .await
-                .map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
-        }
-        for parent_id in remove_parent_ids {
-            remove_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
-                .await
-                .map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
-        }
-
-        tracing::info!(
-            tool = "secrets_update",
-            ?user_id,
-            name = %resolved_name,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Delete one entry by name (or id), or bulk delete entries matching folder \
-        and/or type. Use dry_run=true to preview. \
-        At least one of id, name, folder, or type must be provided.",
-        annotations(title = "Delete Secret Entry", destructive_hint = true)
-    )]
-    async fn secrets_delete(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let user_id = Self::require_user_id(&ctx)?;
-
-        // Safety: require at least one filter.
-        if input.id.is_none()
-            && input.name.is_none()
-            && input.folder.is_none()
-            && input.entry_type.is_none()
-        {
-            return Err(rmcp::ErrorData::invalid_request(
-                "At least one of id, name, folder, or type must be provided.",
-                None,
-            ));
-        }
-
-        tracing::info!(
-            tool = "secrets_delete",
-            ?user_id,
-            id = ?input.id,
-            name = input.name.as_deref(),
-            folder = input.folder.as_deref(),
-            entry_type = input.entry_type.as_deref(),
-            dry_run = input.dry_run.unwrap_or(false),
-            "tool call start",
-        );
-
-        // When id is provided, resolve to name+folder for the single-entry delete path.
-        let (effective_name, effective_folder): (Option, Option) =
-            if let Some(ref id_str) = input.id {
-                let eid = parse_uuid(id_str)?;
-                let uid = user_id;
-                let entry = resolve_entry_by_id(&self.pool, eid, Some(uid))
-                    .await
-                    .map_err(|e| mcp_err_internal_logged("secrets_delete", Some(uid), e))?;
-                (Some(entry.name), Some(entry.folder))
-            } else {
-                (input.name.clone(), input.folder.clone())
-            };
-
-        let result = svc_delete(
-            &self.pool,
-            DeleteParams {
-                name: effective_name.as_deref(),
-                folder: effective_folder.as_deref(),
-                entry_type: input.entry_type.as_deref(),
-                dry_run: input.dry_run.unwrap_or(false),
-                user_id: Some(user_id),
-            },
-        )
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_delete", Some(user_id), e))?;
-
-        tracing::info!(
-            tool = "secrets_delete",
-            ?user_id,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "View change history for an entry. Returns a list of versions with \
-        actions and timestamps. Optionally pass 'id' (from secrets_find) to target directly.",
-        annotations(
-            title = "View Secret History",
-            read_only_hint = true,
-            idempotent_hint = true
-        )
-    )]
-    async fn secrets_history(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let user_id = Self::require_user_id(&ctx)?;
-        tracing::info!(
-            tool = "secrets_history",
-            ?user_id,
-            name = %input.name,
-            id = ?input.id,
-            "tool call start",
-        );
-
-        let (resolved_name, resolved_folder): (String, Option) =
-            if let Some(ref id_str) = input.id {
-                let eid = parse_uuid(id_str)?;
-                let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
-                    .await
-                    .map_err(|e| mcp_err_internal_logged("secrets_history", Some(user_id), e))?;
-                (entry.name, Some(entry.folder))
-            } else {
-                (input.name.clone(), input.folder.clone())
-            };
-
-        let result = svc_history(
-            &self.pool,
-            &resolved_name,
-            resolved_folder.as_deref(),
-            input.limit.unwrap_or(20),
-            Some(user_id),
-        )
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_history", Some(user_id), e))?;
-
-        tracing::info!(
-            tool = "secrets_history",
-            ?user_id,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Rollback an entry to a previous version. Requires Bearer API key only (no encryption key). \
-        Omit to_version to restore the most recent snapshot. \
-        Optionally pass 'id' (from secrets_find) to target directly.",
-        annotations(title = "Rollback Secret Entry", destructive_hint = true)
-    )]
-    async fn secrets_rollback(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let user_id = Self::require_user_id(&ctx)?;
-        tracing::info!(
-            tool = "secrets_rollback",
-            ?user_id,
-            id = %input.id,
-            to_version = input.to_version,
-            "tool call start",
-        );
-        let entry_id = parse_uuid(&input.id)?;
-
-        let result = svc_rollback(&self.pool, entry_id, input.to_version, Some(user_id))
-            .await
-            .map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
-
-        tracing::info!(
-            tool = "secrets_rollback",
-            ?user_id,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \
-        Requires X-Encryption-Key header. Useful for backup or data migration.",
-        annotations(
-            title = "Export Secrets",
-            read_only_hint = true,
-            idempotent_hint = true
-        )
-    )]
-    async fn secrets_export(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let (user_id, user_key) =
-            Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
-        let tags = input.tags.unwrap_or_default();
-        let format = input.format.as_deref().unwrap_or("json");
-        tracing::info!(
-            tool = "secrets_export",
-            ?user_id,
-            folder = input.folder.as_deref(),
-            entry_type = input.entry_type.as_deref(),
-            format,
-            "tool call start",
-        );
-
-        let data = svc_export(
-            &self.pool,
-            ExportParams {
-                folder: input.folder.as_deref(),
-                entry_type: input.entry_type.as_deref(),
-                name: input.name.as_deref(),
-                tags: &tags,
-                query: input.query.as_deref(),
-                no_secrets: false,
-                user_id: Some(user_id),
-            },
-            Some(&user_key),
-        )
-        .await
-        .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
-
-        let fmt = format.parse::().map_err(|e| {
-            tracing::warn!(
-                tool = "secrets_export",
-                ?user_id,
-                error = %e,
-                "invalid export format"
-            );
-            rmcp::ErrorData::invalid_request(
-                "Invalid export format. Use json, toml, or yaml.",
-                None,
-            )
-        })?;
-        let serialized = fmt
-            .serialize(&data)
-            .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
-
-        tracing::info!(
-            tool = "secrets_export",
-            ?user_id,
-            entry_count = data.entries.len(),
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        Ok(CallToolResult::success(vec![Content::text(serialized)]))
-    }
-
-    #[tool(
-        description = "Build the environment variable map from entry secrets with decrypted \
-        plaintext values. Requires X-Encryption-Key header. \
-        Returns a JSON object of VAR_NAME -> plaintext_value ready for injection. \
-        Variable names follow the pattern UPPER(entry_name)_UPPER(field_name), \
-        with hyphens and dots replaced by underscores. \
-        Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID.",
-        annotations(title = "Build Env Map", read_only_hint = true, idempotent_hint = true)
-    )]
-    async fn secrets_env_map(
-        &self,
-        Parameters(input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let (user_id, user_key) =
-            Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?;
-        let tags = input.tags.unwrap_or_default();
-        let only_fields = input.only_fields.unwrap_or_default();
-        tracing::info!(
-            tool = "secrets_env_map",
-            ?user_id,
-            folder = input.folder.as_deref(),
-            entry_type = input.entry_type.as_deref(),
-            prefix = input.prefix.as_deref().unwrap_or(""),
-            "tool call start",
-        );
-
-        let env_map = secrets_core::service::env_map::build_env_map(
-            &self.pool,
-            input.folder.as_deref(),
-            input.entry_type.as_deref(),
-            input.name.as_deref(),
-            &tags,
-            &only_fields,
-            input.prefix.as_deref().unwrap_or(""),
-            &user_key,
-            Some(user_id),
-        )
-        .await
-        .map_err(|e| mcp_err_from_anyhow("secrets_env_map", Some(user_id), e))?;
-
-        let entry_count = env_map.len();
-        tracing::info!(
-            tool = "secrets_env_map",
-            ?user_id,
-            entry_count,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&env_map).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-
-    #[tool(
-        description = "Get an overview of the secrets store: counts of entries per folder and \
-        per type. Requires Bearer API key. Useful for exploring the store structure.",
-        annotations(
-            title = "Secrets Overview",
-            read_only_hint = true,
-            idempotent_hint = true
-        )
-    )]
-    async fn secrets_overview(
-        &self,
-        Parameters(_input): Parameters,
-        ctx: RequestContext,
-    ) -> Result {
-        let t = Instant::now();
-        let user_id = Self::require_user_id(&ctx)?;
-        tracing::info!(tool = "secrets_overview", ?user_id, "tool call start");
-
-        #[derive(sqlx::FromRow)]
-        struct CountRow {
-            name: String,
-            count: i64,
-        }
-
-        let folder_rows: Vec = sqlx::query_as::<_, CountRow>(
-            "SELECT folder AS name, COUNT(*) AS count FROM entries \
-             WHERE user_id = $1 GROUP BY folder ORDER BY folder",
-        )
-        .bind(user_id)
-        .fetch_all(&self.pool)
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?;
-
-        let type_rows: Vec = sqlx::query_as::<_, CountRow>(
-            "SELECT type AS name, COUNT(*) AS count FROM entries \
-             WHERE user_id = $1 GROUP BY type ORDER BY type",
-        )
-        .bind(user_id)
-        .fetch_all(&self.pool)
-        .await
-        .map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?;
-
-        let total: i64 = folder_rows.iter().map(|r| r.count).sum();
-
-        let result = serde_json::json!({
-            "total": total,
-            "folders": folder_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::>(),
-            "types": type_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::>(),
-        });
-
-        tracing::info!(
-            tool = "secrets_overview",
-            ?user_id,
-            total,
-            elapsed_ms = t.elapsed().as_millis(),
-            "tool call ok",
-        );
-        let json = serde_json::to_string_pretty(&result).unwrap_or_default();
-        Ok(CallToolResult::success(vec![Content::text(json)]))
-    }
-}
-
-// ── ServerHandler ─────────────────────────────────────────────────────────────
-
-#[tool_handler]
-impl ServerHandler for SecretsService {
-    fn get_info(&self) -> InitializeResult {
-        let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
-        info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION"))
-            .with_title("Secrets MCP")
-            .with_description(
-                "Secure cross-device secrets and configuration management with encrypted secret fields.",
-            );
-        info.protocol_version = ProtocolVersion::V_2025_06_18;
-        info.instructions = Some(
-            "Manage cross-device secrets and configuration securely. \
-             Use secrets_find to discover entries by folder, name, type, tags, or query \
-             (query also searches metadata values). \
-             Use secrets_get with the entry id (from secrets_find) to decrypt secret values. \
-             Use secrets_add / secrets_update to write entries. \
-             Use secrets_overview for a quick count of entries per folder and type."
-                .to_string(),
-        );
-        info
-    }
-}
-
-#[cfg(test)]
-mod deser_tests {
-    use super::deser;
-    use serde::Deserialize;
-    use serde_json::json;
-
-    #[derive(Deserialize)]
-    struct TestU32 {
-        #[serde(deserialize_with = "deser::option_u32_from_string")]
-        val: Option,
-    }
-
-    #[derive(Deserialize)]
-    struct TestI64 {
-        #[serde(deserialize_with = "deser::option_i64_from_string")]
-        val: Option,
-    }
-
-    #[derive(Deserialize)]
-    struct TestBool {
-        #[serde(deserialize_with = "deser::option_bool_from_string")]
-        val: Option,
-    }
-
-    #[derive(Debug, Deserialize)]
-    struct TestVec {
-        #[serde(deserialize_with = "deser::option_vec_string_from_string")]
-        val: Option>,
-    }
-
-    #[derive(Debug, Deserialize)]
-    struct TestMap {
-        #[serde(deserialize_with = "deser::option_map_from_string")]
-        val: Option>,
-    }
-
-    // option_u32_from_string
-    #[test]
-    fn u32_native_number() {
-        let v: TestU32 = serde_json::from_value(json!({"val": 42})).unwrap();
-        assert_eq!(v.val, Some(42));
-    }
-
-    #[test]
-    fn u32_string_number() {
-        let v: TestU32 = serde_json::from_value(json!({"val": "42"})).unwrap();
-        assert_eq!(v.val, Some(42));
-    }
-
-    #[test]
-    fn u32_empty_string() {
-        let v: TestU32 = serde_json::from_value(json!({"val": ""})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn u32_none() {
-        let v: TestU32 = serde_json::from_value(json!({"val": null})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    // option_i64_from_string
-    #[test]
-    fn i64_native_number() {
-        let v: TestI64 = serde_json::from_value(json!({"val": -100})).unwrap();
-        assert_eq!(v.val, Some(-100));
-    }
-
-    #[test]
-    fn i64_string_number() {
-        let v: TestI64 = serde_json::from_value(json!({"val": "999"})).unwrap();
-        assert_eq!(v.val, Some(999));
-    }
-
-    #[test]
-    fn i64_empty_string() {
-        let v: TestI64 = serde_json::from_value(json!({"val": ""})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn i64_none() {
-        let v: TestI64 = serde_json::from_value(json!({"val": null})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    // option_bool_from_string
-    #[test]
-    fn bool_native_true() {
-        let v: TestBool = serde_json::from_value(json!({"val": true})).unwrap();
-        assert_eq!(v.val, Some(true));
-    }
-
-    #[test]
-    fn bool_native_false() {
-        let v: TestBool = serde_json::from_value(json!({"val": false})).unwrap();
-        assert_eq!(v.val, Some(false));
-    }
-
-    #[test]
-    fn bool_string_true() {
-        let v: TestBool = serde_json::from_value(json!({"val": "true"})).unwrap();
-        assert_eq!(v.val, Some(true));
-    }
-
-    #[test]
-    fn bool_string_false() {
-        let v: TestBool = serde_json::from_value(json!({"val": "false"})).unwrap();
-        assert_eq!(v.val, Some(false));
-    }
-
-    #[test]
-    fn bool_empty_string() {
-        let v: TestBool = serde_json::from_value(json!({"val": ""})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn bool_none() {
-        let v: TestBool = serde_json::from_value(json!({"val": null})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    // option_vec_string_from_string
-    #[test]
-    fn vec_native_array() {
-        let v: TestVec = serde_json::from_value(json!({"val": ["a", "b"]})).unwrap();
-        assert_eq!(v.val, Some(vec!["a".to_string(), "b".to_string()]));
-    }
-
-    #[test]
-    fn vec_json_string_array() {
-        let v: TestVec = serde_json::from_value(json!({"val": "[\"x\",\"y\"]"})).unwrap();
-        assert_eq!(v.val, Some(vec!["x".to_string(), "y".to_string()]));
-    }
-
-    #[test]
-    fn vec_empty_string() {
-        let v: TestVec = serde_json::from_value(json!({"val": ""})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn vec_none() {
-        let v: TestVec = serde_json::from_value(json!({"val": null})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn vec_invalid_string_errors() {
-        let err = serde_json::from_value::(json!({"val": "not-json"}))
-            .expect_err("should fail on invalid JSON");
-        let msg = err.to_string();
-        assert!(msg.contains("invalid string value for array field"));
-        assert!(msg.contains("expected a JSON array"));
-    }
-
-    // option_map_from_string
-    #[test]
-    fn map_native_object() {
-        let v: TestMap = serde_json::from_value(json!({"val": {"key": "value"}})).unwrap();
-        assert!(v.val.is_some());
-        let m = v.val.unwrap();
-        assert_eq!(
-            m.get("key"),
-            Some(&serde_json::Value::String("value".to_string()))
-        );
-    }
-
-    #[test]
-    fn map_json_string_object() {
-        let v: TestMap = serde_json::from_value(json!({"val": "{\"a\":1}"})).unwrap();
-        assert!(v.val.is_some());
-        let m = v.val.unwrap();
-        assert_eq!(m.get("a"), Some(&serde_json::Value::Number(1.into())));
-    }
-
-    #[test]
-    fn map_empty_string() {
-        let v: TestMap = serde_json::from_value(json!({"val": ""})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn map_none() {
-        let v: TestMap = serde_json::from_value(json!({"val": null})).unwrap();
-        assert_eq!(v.val, None);
-    }
-
-    #[test]
-    fn map_invalid_string_errors() {
-        let err = serde_json::from_value::(json!({"val": "not-json"}))
-            .expect_err("should fail on invalid JSON");
-        let msg = err.to_string();
-        assert!(msg.contains("invalid string value for object field"));
-        assert!(msg.contains("expected a JSON object"));
-    }
-}
diff --git a/crates/secrets-mcp/src/validation.rs b/crates/secrets-mcp/src/validation.rs
deleted file mode 100644
index 02a0e30..0000000
--- a/crates/secrets-mcp/src/validation.rs
+++ /dev/null
@@ -1,149 +0,0 @@
-/// Validation constants for input field lengths.
-pub const MAX_NAME_LENGTH: usize = 256;
-pub const MAX_FOLDER_LENGTH: usize = 128;
-pub const MAX_ENTRY_TYPE_LENGTH: usize = 64;
-pub const MAX_NOTES_LENGTH: usize = 10000;
-pub const MAX_TAG_LENGTH: usize = 64;
-pub const MAX_TAG_COUNT: usize = 50;
-pub const MAX_META_KEY_LENGTH: usize = 128;
-pub const MAX_META_VALUE_LENGTH: usize = 4096;
-pub const MAX_META_COUNT: usize = 100;
-
-/// Validate input field lengths for MCP tools.
-///
-/// Returns an error if any field exceeds its maximum length.
-pub fn validate_input_lengths(
-    name: &str,
-    folder: Option<&str>,
-    entry_type: Option<&str>,
-    notes: Option<&str>,
-) -> Result<(), rmcp::ErrorData> {
-    if name.chars().count() > MAX_NAME_LENGTH {
-        return Err(rmcp::ErrorData::invalid_params(
-            format!("name must be at most {} characters", MAX_NAME_LENGTH),
-            None,
-        ));
-    }
-    if let Some(folder) = folder
-        && folder.chars().count() > MAX_FOLDER_LENGTH
-    {
-        return Err(rmcp::ErrorData::invalid_params(
-            format!("folder must be at most {} characters", MAX_FOLDER_LENGTH),
-            None,
-        ));
-    }
-    if let Some(entry_type) = entry_type
-        && entry_type.chars().count() > MAX_ENTRY_TYPE_LENGTH
-    {
-        return Err(rmcp::ErrorData::invalid_params(
-            format!("type must be at most {} characters", MAX_ENTRY_TYPE_LENGTH),
-            None,
-        ));
-    }
-    if let Some(notes) = notes
-        && notes.chars().count() > MAX_NOTES_LENGTH
-    {
-        return Err(rmcp::ErrorData::invalid_params(
-            format!("notes must be at most {} characters", MAX_NOTES_LENGTH),
-            None,
-        ));
-    }
-    Ok(())
-}
-
-/// Validate the tags list.
-///
-/// Checks total count and per-tag character length.
-pub fn validate_tags(tags: &[String]) -> Result<(), rmcp::ErrorData> {
-    if tags.len() > MAX_TAG_COUNT {
-        return Err(rmcp::ErrorData::invalid_params(
-            format!("at most {} tags are allowed", MAX_TAG_COUNT),
-            None,
-        ));
-    }
-    for tag in tags {
-        if tag.chars().count() > MAX_TAG_LENGTH {
-            return Err(rmcp::ErrorData::invalid_params(
-                format!(
-                    "tag '{}' exceeds the maximum length of {} characters",
-                    tag, MAX_TAG_LENGTH
-                ),
-                None,
-            ));
-        }
-    }
-    Ok(())
-}
-
-/// Validate metadata KV strings (key=value / key:=json format).
-///
-/// Checks total count and per-key/per-value character lengths.
-/// This is a best-effort check on the raw KV strings before parsing;
-/// keys containing `:` path separators are checked as a whole.
-pub fn validate_meta_entries(entries: &[String]) -> Result<(), rmcp::ErrorData> {
-    if entries.len() > MAX_META_COUNT {
-        return Err(rmcp::ErrorData::invalid_params(
-            format!("at most {} metadata entries are allowed", MAX_META_COUNT),
-            None,
-        ));
-    }
-    for entry in entries {
-        // key:=json — check both key and JSON value length
-        if let Some((key, value)) = entry.split_once(":=") {
-            if key.chars().count() > MAX_META_KEY_LENGTH {
-                return Err(rmcp::ErrorData::invalid_params(
-                    format!(
-                        "metadata key '{}' exceeds the maximum length of {} characters",
-                        key, MAX_META_KEY_LENGTH
-                    ),
-                    None,
-                ));
-            }
-            if value.chars().count() > MAX_META_VALUE_LENGTH {
-                return Err(rmcp::ErrorData::invalid_params(
-                    format!(
-                        "metadata JSON value for key '{}' exceeds the maximum length of {} characters",
-                        key, MAX_META_VALUE_LENGTH
-                    ),
-                    None,
-                ));
-            }
-            continue;
-        }
-        // key=value or key@path
-        if let Some((key, value)) = entry.split_once('=') {
-            if key.chars().count() > MAX_META_KEY_LENGTH {
-                return Err(rmcp::ErrorData::invalid_params(
-                    format!(
-                        "metadata key '{}' exceeds the maximum length of {} characters",
-                        key, MAX_META_KEY_LENGTH
-                    ),
-                    None,
-                ));
-            }
-            if value.chars().count() > MAX_META_VALUE_LENGTH {
-                return Err(rmcp::ErrorData::invalid_params(
-                    format!(
-                        "metadata value for key '{}' exceeds the maximum length of {} characters",
-                        key, MAX_META_VALUE_LENGTH
-                    ),
-                    None,
-                ));
-            }
-        } else {
-            // Fallback: entry without = or := — check total length
-            let max_total = MAX_META_KEY_LENGTH + MAX_META_VALUE_LENGTH;
-            if entry.chars().count() > max_total {
-                let preview = entry.chars().take(50).collect::();
-                return Err(rmcp::ErrorData::invalid_params(
-                    format!(
-                        "metadata entry '{}' exceeds the maximum length of {} characters",
-                        preview, max_total
-                    ),
-                    None,
-                ));
-            }
-        }
-    }
-    Ok(())
-}
diff --git a/crates/secrets-mcp/src/web/account.rs b/crates/secrets-mcp/src/web/account.rs
deleted file mode 100644
index a7106fb..0000000
--- a/crates/secrets-mcp/src/web/account.rs
+++ /dev/null
@@ -1,297 +0,0 @@
-use askama::Template;
-use axum::{Json, extract::State, http::StatusCode, response::Response};
-use serde::{Deserialize, Serialize};
-use tower_sessions::Session;
-
-use secrets_core::crypto::hex;
-use secrets_core::service::{
-    api_key::{ensure_api_key, regenerate_api_key},
-    user::{change_user_key, get_user_by_id, update_user_key_setup},
-};
-
-use crate::AppState;
-
-use super::{SESSION_KEY_VERSION, load_session_user_strict, render_template, require_valid_user};
-
-#[derive(Template)]
-#[template(path = "dashboard.html")]
-struct DashboardTemplate {
-    user_name: String,
-    user_email: String,
-    has_passphrase: bool,
-    base_url: String,
-    version: &'static str,
-}
-
-#[derive(Serialize)]
-pub(super) struct KeySaltResponse {
-    has_passphrase: bool,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    salt: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    key_check: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    params: Option,
-}
-
-#[derive(Deserialize)]
-pub(super) struct KeySetupRequest {
-    /// Hex-encoded 32-byte random salt
-    salt: String,
-    /// Hex-encoded AES-256-GCM encryption of "secrets-mcp-key-check" with the derived key
-    key_check: String,
-    /// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}
-    params: serde_json::Value,
-}
-
-#[derive(Serialize)]
-pub(super) struct KeySetupResponse {
-    ok: bool,
-}
-
-#[derive(Deserialize)]
-pub(super) struct KeyChangeRequest {
-    /// Old derived key as 64-char hex — used to decrypt existing secrets
-    old_key: String,
-    /// New derived key as 64-char hex — used to re-encrypt secrets
-    new_key: String,
-    /// New 32-byte hex salt
-    salt: String,
-    /// New key_check: AES-256-GCM of KEY_CHECK_PLAINTEXT with the new key (hex)
-    key_check: String,
-    /// New key derivation parameters
-    params: serde_json::Value,
-}
-
-#[derive(Serialize)]
-pub(super) struct ApiKeyResponse {
-    api_key: String,
-}
-
-pub(super) async fn dashboard(
-    State(state): State,
-    session: Session,
-) -> Result {
-    let user = match require_valid_user(&state.pool, &session, "dashboard").await {
-        Ok(u) => u,
-        Err(r) => return Ok(r),
-    };
-
-    let tmpl = DashboardTemplate {
-        user_name: user.name.clone(),
-        user_email: user.email.clone().unwrap_or_default(),
-        has_passphrase: user.key_salt.is_some(),
-        base_url: state.base_url.clone(),
-        version: env!("CARGO_PKG_VERSION"),
-    };
-
-    render_template(tmpl)
-}
-
-pub(super) async fn api_key_salt(
-    State(state): State,
-    session: Session,
-) -> Result, StatusCode> {
-    let user = match load_session_user_strict(&state.pool, &session).await {
-        Ok(Some(u)) => u,
-        Ok(None) => return Err(StatusCode::UNAUTHORIZED),
-        Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
-    };
-
-    if user.key_salt.is_none() {
-        return Ok(Json(KeySaltResponse {
-            has_passphrase: false,
-            salt: None,
-            key_check: None,
-            params: None,
-        }));
-    }
-
-    Ok(Json(KeySaltResponse {
-        has_passphrase: true,
-        salt: user.key_salt.as_deref().map(hex::encode_hex),
-        key_check: user.key_check.as_deref().map(hex::encode_hex),
-        params: user.key_params,
-    }))
-}
-
-pub(super) async fn api_key_setup(
-    State(state): State,
-    session: Session,
-    Json(body): Json,
-) -> Result, StatusCode> {
-    let user = match load_session_user_strict(&state.pool, &session).await {
-        Ok(Some(u)) => u,
-        Ok(None) => return Err(StatusCode::UNAUTHORIZED),
-        Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
-    };
-    let user_id = user.id;
-
-    // Guard: if a passphrase is already configured, reject and direct to /api/key-change
-    if user.key_salt.is_some() {
-        tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
-        return Err(StatusCode::CONFLICT);
-    }
-
-    let salt = hex::decode_hex(&body.salt).map_err(|e| {
-        tracing::warn!(error = %e, "invalid hex in key-setup salt");
-        StatusCode::BAD_REQUEST
-    })?;
-    let key_check = hex::decode_hex(&body.key_check).map_err(|e| {
-        tracing::warn!(error = %e, "invalid hex in key-setup key_check");
-        StatusCode::BAD_REQUEST
-    })?;
-
-    if salt.len() != 32 {
-        tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes");
-        return Err(StatusCode::BAD_REQUEST);
-    }
-
-    update_user_key_setup(&state.pool, user_id, &salt, &key_check, &body.params)
-        .await
-        .map_err(|e| {
-            tracing::error!(error = %e, "failed to update key setup");
-            StatusCode::INTERNAL_SERVER_ERROR
-        })?;
-
-    Ok(Json(KeySetupResponse { ok: true }))
-}
-
-// ── Change passphrase (re-encrypts all secrets) ───────────────────────────────
-
-pub(super) async fn api_key_change(
-    State(state): State,
-    session: Session,
-    Json(body): Json,
-) -> Result, StatusCode> {
-    let user = match load_session_user_strict(&state.pool, &session).await {
-        Ok(Some(u)) => u,
-        Ok(None) => return Err(StatusCode::UNAUTHORIZED),
-        Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
-    };
-    let user_id = user.id;
-
-    // Must have an existing passphrase to change
-    let existing_key_check = user.key_check.ok_or_else(|| {
-        tracing::warn!(%user_id, "key-change called but no passphrase configured; use /api/key-setup");
-        StatusCode::BAD_REQUEST
-    })?;
-
-    // Validate and decode old key
-    let old_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.old_key).map_err(|e| {
-        tracing::warn!(error = %e, "invalid old_key hex in key-change");
-        StatusCode::BAD_REQUEST
-    })?;
-
-    // Verify old_key against the stored key_check
-    let plaintext = secrets_core::crypto::decrypt(&old_key_bytes, &existing_key_check).map_err(|_| {
-        tracing::warn!(%user_id, "key-change rejected: old_key does not match stored key_check");
-        StatusCode::UNAUTHORIZED
-    })?;
-    if plaintext != b"secrets-mcp-key-check" {
-        tracing::warn!(%user_id, "key-change rejected: decrypted key_check content mismatch");
-        return Err(StatusCode::UNAUTHORIZED);
-    }
-
-    // Validate and decode new key
-    let new_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.new_key).map_err(|e| {
-        tracing::warn!(error = %e, "invalid new_key hex in key-change");
-        StatusCode::BAD_REQUEST
-    })?;
-
-    // Decode new salt and key_check
-    let new_salt = hex::decode_hex(&body.salt).map_err(|e| {
-        tracing::warn!(error = %e, "invalid hex in key-change salt");
-        StatusCode::BAD_REQUEST
-    })?;
-    if new_salt.len() != 32 {
-        tracing::warn!(
-            salt_len = new_salt.len(),
-            "key-change salt must be 32 bytes"
-        );
-        return Err(StatusCode::BAD_REQUEST);
-    }
-    let new_key_check = hex::decode_hex(&body.key_check).map_err(|e| {
-        tracing::warn!(error = %e, "invalid hex in key-change key_check");
-        StatusCode::BAD_REQUEST
-    })?;
-
-    change_user_key(
-        &state.pool,
-        user_id,
-        &old_key_bytes,
-        &new_key_bytes,
-        &new_salt,
-        &new_key_check,
-        &body.params,
-    )
-    .await
-    .map_err(|e| {
-        tracing::error!(error = %e, %user_id, "failed to change user key");
-        StatusCode::INTERNAL_SERVER_ERROR
-    })?;
-
-    // Refresh the session's key_version so the current session is not immediately
-    // invalidated by require_valid_user on the next page load.
-    match get_user_by_id(&state.pool, user_id).await {
-        Ok(Some(updated_user)) => {
-            if let Err(e) = session
-                .insert(SESSION_KEY_VERSION, updated_user.key_version)
-                .await
-            {
-                tracing::warn!(error = %e, %user_id, "failed to update key_version in session after key change");
-            }
-        }
-        Ok(None) => {
-            tracing::warn!(%user_id, "user not found after key change; session not updated");
-        }
-        Err(e) => {
-            tracing::warn!(error = %e, %user_id, "failed to reload user after key change; session not updated");
-        }
-    }
-
-    tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted");
-    Ok(Json(KeySetupResponse { ok: true }))
-}
-
-// ── API Key management ────────────────────────────────────────────────────────
-
-pub(super) async fn api_apikey_get(
-    State(state): State,
-    session: Session,
-) -> Result, StatusCode> {
-    let user = match load_session_user_strict(&state.pool, &session).await {
-        Ok(Some(u)) => u,
-        Ok(None) => return Err(StatusCode::UNAUTHORIZED),
-        Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
-    };
-    let user_id = user.id;
-
-    let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
-        tracing::error!(error = %e, %user_id, "ensure_api_key failed");
-        StatusCode::INTERNAL_SERVER_ERROR
-    })?;
-
-    Ok(Json(ApiKeyResponse { api_key }))
-}
-
-pub(super) async fn api_apikey_regenerate(
-    State(state): State,
-    session: Session,
-) -> Result, StatusCode> {
-    let user = match load_session_user_strict(&state.pool, &session).await {
-        Ok(Some(u)) => u,
-        Ok(None) => return Err(StatusCode::UNAUTHORIZED),
-        Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
-    };
-    let user_id = user.id;
-
-    let api_key = regenerate_api_key(&state.pool, user_id)
-        .await
-        .map_err(|e| {
-            tracing::error!(error = %e, %user_id, "regenerate_api_key failed");
-            StatusCode::INTERNAL_SERVER_ERROR
-        })?;
-
-    Ok(Json(ApiKeyResponse { api_key }))
-}
diff --git a/crates/secrets-mcp/src/web/assets.rs b/crates/secrets-mcp/src/web/assets.rs
deleted file mode 100644
index b24da40..0000000
--- a/crates/secrets-mcp/src/web/assets.rs
+++ /dev/null
@@ -1,73 +0,0 @@
-use axum::{
-    body::Body,
-    extract::State,
-    http::{StatusCode, header},
-    response::{IntoResponse, Response},
-};
-
-use crate::AppState;
-
-pub(super) fn text_asset_response(content: &'static str, content_type: &'static str) -> Response {
-    Response::builder()
-        .status(StatusCode::OK)
-        .header(header::CONTENT_TYPE, content_type)
-        .header(header::CACHE_CONTROL, "public, max-age=86400")
-        .body(Body::from(content))
-        .expect("text asset response")
-}
-
-pub(super) async fn robots_txt() -> Response {
-    text_asset_response(
-        include_str!("../../static/robots.txt"),
-        "text/plain; charset=utf-8",
-    )
-}
-
-pub(super) async fn llms_txt() -> Response {
-    text_asset_response(
-        include_str!("../../static/llms.txt"),
-        "text/markdown; charset=utf-8",
-    )
-}
-
-pub(super) async fn ai_txt() -> Response {
-    llms_txt().await
-}
-
-pub(super) async fn i18n_js() -> Response {
-    text_asset_response(
-        include_str!("../../templates/i18n.js"),
-        "application/javascript; charset=utf-8",
-    )
-}
-
-pub(super) async fn favicon_svg() -> Response {
-    Response::builder()
-        .status(StatusCode::OK)
-        .header(header::CONTENT_TYPE, "image/svg+xml")
-        .header(header::CACHE_CONTROL, "public, max-age=86400")
-        .body(Body::from(include_str!("../../static/favicon.svg")))
-        .expect("favicon response")
-}
-
-/// RFC 9728 — OAuth 2.0 Protected Resource Metadata.
-///
-/// Advertises that this server accepts Bearer tokens in the `Authorization`
-/// header.  We deliberately omit `authorization_servers` because this service
-/// issues its own API keys (no external OAuth AS is involved).  MCP clients
-/// that probe this endpoint will see the resource identifier and stop looking
-/// for a delegated OAuth flow.
-pub(super) async fn oauth_protected_resource_metadata(
-    State(state): State,
-) -> impl IntoResponse {
-    let body = serde_json::json!({
-        "resource": state.base_url,
-        "bearer_methods_supported": ["header"],
-        "resource_documentation": format!("{}/dashboard", state.base_url),
-    });
-    (
-        StatusCode::OK,
-        [(header::CONTENT_TYPE, "application/json")],
-        axum::Json(body),
-    )
-}
diff --git a/crates/secrets-mcp/src/web/audit.rs b/crates/secrets-mcp/src/web/audit.rs
deleted file mode 100644
index 6087471..0000000
--- a/crates/secrets-mcp/src/web/audit.rs
+++ /dev/null
@@ -1,104 +0,0 @@
-use askama::Template;
-use axum::{
-    extract::{Query, State},
-    http::StatusCode,
-    response::Response,
-};
-use chrono::SecondsFormat;
-use serde::Deserialize;
-use tower_sessions::Session;
-
-use crate::AppState;
-
-use super::{AUDIT_PAGE_LIMIT, paginate, render_template, require_valid_user};
-
-#[derive(Template)]
-#[template(path = "audit.html")]
-struct AuditPageTemplate {
-    user_name: String,
-    user_email: String,
-    entries: Vec,
-    current_page: u32,
-    total_pages: u32,
-    total_count: i64,
-    version: &'static str,
-}
-
-struct AuditEntryView {
-    /// RFC3339 UTC for `