diff --git a/Dockerfile b/Dockerfile index d9f3682..37bd484 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY alembic.ini ./ COPY alembic ./alembic COPY gnexus_creds ./gnexus_creds +COPY extensions ./extensions RUN pip install --no-cache-dir . diff --git a/README.md b/README.md index 13263a8..f684848 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,27 @@ dependency consumers. If `dist/` is missing from the installed package, the UI build cannot resolve `gnexus-ui-kit/vue` or the kit CSS. +## Extension + +A cross-browser extension for gnexus-creds is built in the separate +`gnexus-creds-extension` repository. Packaged builds are distributed from this +service: signed-in users can open the **Extension** tab to download the Chrome +and Firefox builds, view checksums, and follow install instructions. + +Packaged archives live in `extensions/dist/` and are described by +`extensions/manifest.json` (version, checksums, sizes). Both are committed to +this repository and served by FastAPI via: + +```text +GET /api/v1/extension -> build metadata (auth required) +GET /api/v1/extension/download/{file} -> download a build (auth required) +``` + +To publish a new build: run `make` in the `gnexus-creds-extension` repo, copy +the resulting `dist/*.zip` files into `extensions/dist/`, recompute +`sha256`/`size`, and update `extensions/manifest.json` (`version`, +`released_at`, `notes`, and the per-build entries). + ## Tests ```bash diff --git a/extensions/dist/gnexus-creds-extension-chrome-0.1.0.zip b/extensions/dist/gnexus-creds-extension-chrome-0.1.0.zip new file mode 100644 index 0000000..edfce2a --- /dev/null +++ b/extensions/dist/gnexus-creds-extension-chrome-0.1.0.zip Binary files differ diff --git a/extensions/dist/gnexus-creds-extension-firefox-0.1.0.zip b/extensions/dist/gnexus-creds-extension-firefox-0.1.0.zip new file mode 100644 index 0000000..15a7064 --- /dev/null +++ b/extensions/dist/gnexus-creds-extension-firefox-0.1.0.zip Binary files differ diff --git a/extensions/manifest.json b/extensions/manifest.json new file mode 100644 index 0000000..8edb541 --- /dev/null +++ b/extensions/manifest.json @@ -0,0 +1,19 @@ +{ + "version": "0.1.0", + "released_at": "2026-08-24T00:00:00Z", + "notes": "Initial public build: autofill, save prompt, popup UI.", + "builds": [ + { + "browser": "chrome", + "filename": "gnexus-creds-extension-chrome-0.1.0.zip", + "sha256": "323750cad0c1fddc02406ee09b339aaacc99065293c92155d0b363cf220108fc", + "size": 5048772 + }, + { + "browser": "firefox", + "filename": "gnexus-creds-extension-firefox-0.1.0.zip", + "sha256": "7f9c089f47be9902697024999fed85caf121835a05d15b9100b9bb9c2a0ce9e1", + "size": 5048800 + } + ] +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5f70419..e602ee6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -42,6 +42,7 @@ { id: "history", label: "History" }, { id: "audit", label: "Audit" }, { id: "tokens", label: "Tokens" }, + { id: "extension", label: "Extension" }, { id: "settings", label: "Settings" } ]; const tabRoutes = { @@ -49,6 +50,7 @@ history: "/history", audit: "/audit", tokens: "/tokens", + extension: "/extension", settings: "/settings", admin: "/admin" }; @@ -112,6 +114,10 @@ const showRestoreConfirm = ref(false); const pendingRestoreFile = ref(null); +const extensionMeta = ref(null); +const extensionLoading = ref(false); +const extensionError = ref(""); + const form = reactive({ title: "", purpose: "", @@ -161,6 +167,7 @@ { id: "history", label: "History", icon: "ph-clock-counter-clockwise" }, { id: "audit", label: "Audit", icon: "ph-list-checks" }, { id: "tokens", label: "Tokens", icon: "ph-key" }, + { id: "extension", label: "Extension", icon: "ph-puzzle-piece" }, { id: "settings", label: "Settings", icon: "ph-gear" } ]; if (me.value?.role === "admin") { @@ -896,6 +903,34 @@ stats.mcp_enabled_secrets = payload.mcp_enabled_secrets; } +async function loadExtension() { + if (extensionMeta.value || extensionLoading.value) return; + extensionLoading.value = true; + extensionError.value = ""; + try { + extensionMeta.value = await api.extension(); + } catch (err) { + extensionError.value = err.message || "Failed to load extension info."; + } finally { + extensionLoading.value = false; + } +} + +function formatBytes(bytes) { + if (!bytes && bytes !== 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function downloadExtension(filename) { + window.location.href = api.downloadExtension(filename); +} + +function goToTokens() { + activeTab.value = "tokens"; +} + onMounted(async () => { try { me.value = await api.me(); @@ -940,6 +975,9 @@ if (tab === "tokens") { loadTokens(); } + if (tab === "extension") { + loadExtension(); + } if (tab === "settings") { loadStats(); } @@ -1231,6 +1269,76 @@ +
+ {{ extensionError }} + +
+ +

+ Autofill saved credentials, capture new logins, and browse secrets from the toolbar. + Download the build for your browser, load it unpacked, and point it at this server with + an API token. +

+

{{ extensionMeta.notes }}

+ +
+ +
+ +
+
+
+ {{ build.browser }} + {{ formatBytes(build.size) }} +
+
+ SHA-256 + +
+
+ + Download {{ build.browser }} build + +
+
+
+ +
+ Create an API token +
+
+ +
+ + +
+
Chrome / Edge
+
+ Open chrome://extensions/, enable Developer mode, click + "Load unpacked" and select the unzipped build folder. Then open the popup, set the + server URL and an API token with read, reveal, and + write scopes. +
+
+
+
Firefox
+
+ Open about:debugging, choose "This Firefox", click "Load Temporary + Add-on" and select the manifest.json from the unzipped build. Configure + the server URL and API token in the popup. +
+
+
+
+
+
diff --git a/frontend/src/api.js b/frontend/src/api.js index d2e7c84..c2bf929 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -83,6 +83,8 @@ return request(`/api/v1/admin/users?${query}`); }, stats: () => request("/api/v1/stats"), + extension: () => request("/api/v1/extension"), + downloadExtension: (filename) => `/api/v1/extension/download/${encodeURIComponent(filename)}`, createBackup: () => request("/api/v1/admin/backup", { method: "POST" }), listBackups: () => request("/api/v1/admin/backups"), downloadBackup: (filename) => `/api/v1/admin/backups/${encodeURIComponent(filename)}`, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8223785..ff254c6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -123,6 +123,40 @@ align-items: center; } +.extension-builds { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.extension-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid var(--gn-border, rgba(255, 255, 255, 0.08)); + border-radius: 12px; + background: var(--gn-surface-2, rgba(255, 255, 255, 0.02)); +} + +.extension-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.extension-card-sha { + display: flex; + align-items: center; + gap: 8px; +} + +.extension-loading { + margin-top: 16px; +} + .danger-zone { border: 2px solid rgba(247, 118, 142, 0.28); background: rgba(247, 118, 142, 0.04); diff --git a/gnexus_creds/api.py b/gnexus_creds/api.py index a1ce7b7..5fdeb1f 100644 --- a/gnexus_creds/api.py +++ b/gnexus_creds/api.py @@ -1,6 +1,8 @@ """REST API routes.""" +import json from datetime import UTC, datetime +from pathlib import Path from uuid import UUID from fastapi import APIRouter, Depends, Query @@ -9,6 +11,7 @@ from sqlalchemy.orm import Session, selectinload from gnexus_creds.auth import actor_from_request, require_admin +from gnexus_creds.backup import create_backup, list_backups, restore_backup from gnexus_creds.config import get_settings from gnexus_creds.db import get_db from gnexus_creds.errors import AppError @@ -19,6 +22,8 @@ ApiTokenRead, AuditEventRead, ExportResponse, + ExtensionBuildRead, + ExtensionRead, ImportPayload, Page, Scope, @@ -33,7 +38,6 @@ UserRead, UserUpdate, ) -from gnexus_creds.backup import create_backup, list_backups, restore_backup from gnexus_creds.services import ( Actor, audit, @@ -574,3 +578,78 @@ active_secrets=active, mcp_enabled_secrets=mcp, ) + + +def _extension_dir() -> Path: + return Path(get_settings().extension_dir) + + +def _load_extension_manifest() -> dict: + manifest_path = _extension_dir() / "manifest.json" + if not manifest_path.is_file(): + raise AppError("extension_not_found", "Extension manifest not found.", status_code=404) + with manifest_path.open(encoding="utf-8") as fh: + return json.load(fh) + + +def _extension_build_path(filename: str) -> Path: + return _extension_dir() / "dist" / filename + + +@router.get( + "/extension", response_model=ExtensionRead, tags=["extension"], summary="List extension builds" +) +async def extension_info(actor: Actor = Depends(actor_from_request)) -> ExtensionRead: + actor.require(Scope.read) + raw = _load_extension_manifest() + builds: list[ExtensionBuildRead] = [] + for build in raw.get("builds", []): + filename = build["filename"] + path = _extension_build_path(filename) + if not path.is_file(): + continue + builds.append( + ExtensionBuildRead( + browser=build["browser"], + filename=filename, + sha256=build["sha256"], + size=build.get("size", path.stat().st_size), + download_url=f"/api/v1/extension/download/{filename}", + released_at=raw.get("released_at"), + notes=raw.get("notes"), + ) + ) + return ExtensionRead( + version=raw.get("version", ""), + released_at=raw.get("released_at"), + notes=raw.get("notes"), + builds=builds, + ) + + +@router.get( + "/extension/download/{filename}", + tags=["extension"], + summary="Download an extension build", +) +async def extension_download( + filename: str, + db: Session = Depends(get_db), + actor: Actor = Depends(actor_from_request), +) -> FileResponse: + actor.require(Scope.read) + raw = _load_extension_manifest() + match = next((b for b in raw.get("builds", []) if b["filename"] == filename), None) + if match is None: + raise AppError("not_found", "Extension build not found.", status_code=404) + path = _extension_build_path(filename) + if not path.is_file(): + raise AppError("not_found", "Extension build not found.", status_code=404) + audit( + db, + actor, + action="extension.downloaded", + metadata={"browser": match["browser"], "filename": filename}, + ) + db.commit() + return FileResponse(path, filename=filename) diff --git a/gnexus_creds/config.py b/gnexus_creds/config.py index 74f2d41..e55d766 100644 --- a/gnexus_creds/config.py +++ b/gnexus_creds/config.py @@ -34,6 +34,7 @@ mcp_resource_url: str = "http://localhost:8000/mcp-protocol/" cors_origins: list[str] = Field(default=["*"], alias="GNEXUS_CREDS_CORS_ORIGINS") backup_dir: str = Field(default="./backups", alias="GNEXUS_CREDS_BACKUP_DIR") + extension_dir: str = Field(default="./extensions", alias="GNEXUS_CREDS_EXTENSION_DIR") rate_limit_window_seconds: int = 60 rate_limit_max_sensitive_requests: int = 120 diff --git a/gnexus_creds/schemas.py b/gnexus_creds/schemas.py index d6ba71d..236f615 100644 --- a/gnexus_creds/schemas.py +++ b/gnexus_creds/schemas.py @@ -191,3 +191,20 @@ total_secrets: int active_secrets: int mcp_enabled_secrets: int + + +class ExtensionBuildRead(BaseModel): + browser: str + filename: str + sha256: str + size: int + download_url: str + released_at: datetime | None = None + notes: str | None = None + + +class ExtensionRead(BaseModel): + version: str + released_at: datetime | None = None + notes: str | None = None + builds: list[ExtensionBuildRead] diff --git a/tests/test_extension.py b/tests/test_extension.py new file mode 100644 index 0000000..cbe99a5 --- /dev/null +++ b/tests/test_extension.py @@ -0,0 +1,96 @@ +"""Tests for extension distribution endpoints.""" + +import json +import zipfile +from unittest.mock import MagicMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from gnexus_creds import api as api_module + + +def _make_zip(path) -> None: + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("manifest.json", "{}") + + +@pytest.fixture +def extension_dir(tmp_path): + ext = tmp_path / "extensions" + dist = ext / "dist" + dist.mkdir(parents=True) + chrome = dist / "gnexus-creds-extension-chrome-0.1.0.zip" + firefox = dist / "gnexus-creds-extension-firefox-0.1.0.zip" + for path in (chrome, firefox): + _make_zip(path) + manifest = { + "version": "0.1.0", + "released_at": "2026-08-24T00:00:00Z", + "notes": "test build", + "builds": [ + { + "browser": "chrome", + "filename": chrome.name, + "sha256": "a" * 64, + "size": chrome.stat().st_size, + }, + { + "browser": "firefox", + "filename": firefox.name, + "sha256": "b" * 64, + "size": firefox.stat().st_size, + }, + ], + } + (ext / "manifest.json").write_text(json.dumps(manifest)) + return ext + + +@pytest.fixture +def patched_settings(extension_dir): + settings = MagicMock() + settings.extension_dir = str(extension_dir) + with patch.object(api_module, "get_settings", return_value=settings): + yield + + +@pytest.mark.anyio +async def test_extension_info_lists_builds(app, patched_settings): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension") + assert response.status_code == 200, response.text + data = response.json() + assert data["version"] == "0.1.0" + assert data["notes"] == "test build" + browsers = {build["browser"] for build in data["builds"]} + assert browsers == {"chrome", "firefox"} + for build in data["builds"]: + assert build["download_url"].startswith("/api/v1/extension/download/") + assert build["size"] > 0 + assert len(build["sha256"]) == 64 + + +@pytest.mark.anyio +async def test_extension_download_returns_file(app, patched_settings, extension_dir): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + filename = "gnexus-creds-extension-chrome-0.1.0.zip" + response = await client.get(f"/api/v1/extension/download/{filename}") + assert response.status_code == 200, response.text + assert "attachment" in response.headers.get("content-disposition", "") + assert filename in response.headers["content-disposition"] + assert len(response.content) > 0 + + +@pytest.mark.anyio +async def test_extension_download_unknown_returns_404(app, patched_settings): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/download/does-not-exist.zip") + assert response.status_code == 404 + + +@pytest.mark.anyio +async def test_extension_info_requires_auth(auth_app, patched_settings): + async with AsyncClient(transport=ASGITransport(app=auth_app), base_url="http://test") as client: + response = await client.get("/api/v1/extension") + assert response.status_code == 401 \ No newline at end of file