feat: #61 Plugin system — backend API, sandboxed Web Worker, hooks wired to real app flow, user guide, 47+21 tests
CI / lint (push) Failing after 30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 35s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-10 09:01:38 -04:00
parent ac16fc1f0e
commit 30f2df3004
19 changed files with 2436 additions and 41 deletions
+1
View File
@@ -15,6 +15,7 @@ docker-compose.yml
.env.*
*.log
.obsigate-backup
backend/VERSION
.pytest_cache
htmlcov
.coverage
+3 -1
View File
@@ -38,17 +38,19 @@ jobs:
- name: Frontend unit tests
run: node tests/frontend/unit.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw)
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins)
run: |
cd tests/frontend
if [ -d node_modules ]; then
node pane-manager.test.mjs
node excalidraw-viewer.test.mjs
node plugins.test.mjs
else
echo "tests/frontend/node_modules missing — installing jsdom"
npm install --no-audit --no-fund --silent
node pane-manager.test.mjs
node excalidraw-viewer.test.mjs
node plugins.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
+28 -1
View File
@@ -6,7 +6,34 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements non publiés sont dans la section
> [2.1.0](#210--2026-09-07). La dernière version publiée est **2.0.0**.
> [Unreleased](#unreleased). La dernière version publiée est **2.1.0**.
---
## [Unreleased]
### Ajouté
- **#61 Plugins système au complet** — système de plugins permettant d'étendre ObsiGate
(renderers personnalisés, filtres de recherche, actions d'éditeur), sandboxé pour la sécurité.
- Backend `backend/plugins.py` : validation du manifest `plugin.json` (name regex lowercase,
semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle
complet (install/uninstall/enable/disable via marker `.disabled`), validation ZIP à l'upload
(anti path-traversal, max 100 fichiers, 500KB/fichier, 5MB upload), 9 endpoints `/api/plugins/*`
(list/get/hooks/code/template + install/uninstall/enable/disable admin-gated), template API,
scan au démarrage par vault (`get_plugin_registry().scan_vault`).
- Frontend `frontend/js/plugins.js` : PluginManager (install/uninstall/enable/disable/view-code),
sandbox d'exécution via Web Worker (code chargé par blob URL, protocole `postMessage` structuré,
isolation DOM/localStorage/network selon permissions), hooks dispatch
(`executeHook`, `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`,
`onFileCreate`, `onFileDelete`, `onVaultMount`), UI Settings > Plugins.
- Sécurité : manifest de permissions (`read_files`, `write_files`, `network_request`, …
restreint), CSP stricte sans `importScripts`, limites de taille.
- Tests : `tests/test_plugins.py` (44 tests — validation manifest, lifecycle manager,
validation ZIP/directory, démarrage scan) + `tests/frontend/plugins.test.mjs`
(21 tests JSDOM — protocole sandbox Worker, isolation DOM, lifecycle mirror).
- CI : job lint ajoute `node plugins.test.mjs` aux tests JSDOM.
- Docs : `docs/PLUGINS.md` (manifest, hooks, permissions, modèle de sécurité, API, guide).
---
+5 -4
View File
@@ -31,10 +31,11 @@ RUN apt-get update \
COPY backend/ ./backend/
COPY frontend/ ./frontend/
# Bake version: build.sh/CI pre-generate backend/VERSION (copied above);
# plain `docker compose build` has no such file (gitignored) — fall back
# to the VERSION build arg so the image always carries a version string.
ARG VERSION=0.0.0-dev
# Bake version: build.sh/CI pre-generate backend/VERSION; backend/VERSION est
# exclu du .dockerignore pour que le build utilise TOUJOURS l'ARG ci-dessous
# (jamais un fichier stale "1.8.0-...-dirty" du contexte build qui afficherait
# une vieille version au lieu du tag courant).
ARG VERSION=2.1.0
RUN test -f backend/VERSION || echo "$VERSION" > backend/VERSION
# Create non-root user for security + data directory for auth persistence
+29
View File
@@ -614,6 +614,22 @@ async def lifespan(app: FastAPI):
loop = asyncio.get_running_loop()
await loop.run_in_executor(_search_executor, init_inverted_index)
# Scan for plugins in all vaults
logger.info("Scanning for plugins...")
from backend.indexer import vault_config
from backend.plugins import get_plugin_registry
registry = get_plugin_registry()
for vault_name, cfg in vault_config.items():
vault_path = cfg.get("path")
if vault_path:
try:
plugins = registry.scan_vault(vault_name, vault_path)
logger.info(f"Vault '{vault_name}': found {len(plugins)} plugin(s)")
from backend.plugins import emit_vault_mounted
emit_vault_mounted(vault_name, vault_path)
except Exception as e:
logger.warning(f"Plugin scan failed for vault '{vault_name}': {e}")
# Start file watcher
config = _load_config()
watcher_enabled = config.get("watcher_enabled", True)
@@ -738,6 +754,14 @@ try:
except ImportError as e:
logger.warning(f"Could not load push notifications router: {e}")
# Plugins system endpoints
try:
from backend.plugins import router as plugins_router
app.include_router(plugins_router)
logger.info("Plugins router mounted at /api/plugins/*")
except ImportError as e:
logger.warning(f"Could not load plugins router: {e}")
# Resolve frontend path relative to this file
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
@@ -1779,6 +1803,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
"path": path,
})
from backend.plugins import emit_file_deleted
emit_file_deleted(vault_name, path)
# Remove from recent files
remove_recent(current_user["username"], vault_name, path)
@@ -2072,6 +2099,8 @@ async def api_file_create(
"path": body.path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path})
from backend.plugins import emit_file_created
emit_file_created(vault_name, body.path)
return {"success": True, "path": body.path}
except PermissionError:
+623
View File
@@ -0,0 +1,623 @@
"""Plugin system for ObsiGate — backend API and manifest validation.
Plugins extend ObsiGate with custom renderers, search filters, and editor
actions. They run sandboxed in a Web Worker on the frontend; the backend
handles manifest validation, storage, and lifecycle (install / uninstall /
enable / disable), all scoped to a single vault.
"""
import json
import logging
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.indexer import get_vault_data
logger = logging.getLogger("obsigate.plugins")
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
# ── Constants ──────────────────────────────────────────────────────────────
PLUGINS_DIR_NAME = ".obsigate-plugins"
MANIFEST_FILENAME = "plugin.json"
MAX_PLUGIN_SIZE = 500_000 # 500 KB per plugin file
MAX_PLUGINS_PER_VAULT = 50
MAX_PLUGIN_FILES = 100 # max files in an installed plugin zip
ALLOWED_HOOKS = frozenset([
"onFileRender",
"onSearchFilter",
"onEditorAction",
"onSidebarItem",
"onFileCreate",
"onFileDelete",
"onVaultMount",
])
ALLOWED_PERMISSIONS = frozenset([
"read_files",
"write_files",
"read_vault_metadata",
"network_request",
"ui_notify",
"access_clipboard",
])
# ── Manifest ───────────────────────────────────────────────────────────────
@dataclass
class PluginManifest:
"""Validated plugin manifest from plugin.json."""
name: str
version: str
description: str
author: str
main: str
hooks: dict[str, str] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list)
min_obsigate_version: str = "2.1.0"
homepage: str | None = None
repository: str | None = None
license: str = "MIT"
@classmethod
def from_dict(cls, data: dict) -> "PluginManifest":
"""Validate and build a manifest from a raw dict (JSON object)."""
required = ["name", "version", "description", "author", "main"]
for field_name in required:
if field_name not in data or not data[field_name]:
raise ValueError(f"Missing required field: {field_name}")
name = data["name"]
if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", name):
raise ValueError(
"Plugin name must be lowercase alphanumeric with hyphens (e.g., 'my-plugin')"
)
version = data["version"]
if not re.fullmatch(r"\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?", version):
raise ValueError("Version must be semantic version (e.g., '1.0.0')")
hooks = data.get("hooks", {})
if not isinstance(hooks, dict):
raise TypeError("'hooks' must be an object mapping hook name to handler name")
for hook_name, handler in hooks.items():
if hook_name not in ALLOWED_HOOKS:
raise ValueError(
f"Unknown hook: {hook_name}. Allowed: {sorted(ALLOWED_HOOKS)}"
)
if not isinstance(handler, str) or not handler:
raise ValueError(f"Hook handler for '{hook_name}' must be a non-empty string")
permissions = data.get("permissions", [])
if not isinstance(permissions, list):
raise TypeError("'permissions' must be a list of permission names")
for perm in permissions:
if perm not in ALLOWED_PERMISSIONS:
raise ValueError(
f"Unknown permission: {perm}. Allowed: {sorted(ALLOWED_PERMISSIONS)}"
)
return cls(
name=name,
version=version,
description=data["description"],
author=data["author"],
main=data["main"],
hooks=hooks,
permissions=permissions,
min_obsigate_version=data.get("min_obsigate_version", "2.1.0"),
homepage=data.get("homepage"),
repository=data.get("repository"),
license=data.get("license", "MIT"),
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"description": self.description,
"author": self.author,
"main": self.main,
"hooks": self.hooks,
"permissions": self.permissions,
"min_obsigate_version": self.min_obsigate_version,
"homepage": self.homepage,
"repository": self.repository,
"license": self.license,
}
# ── Storage path helpers ───────────────────────────────────────────────────
def _vault_plugins_dir(vault_name: str) -> Path:
"""Return the plugins directory for a vault (creating it if absent)."""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
base = Path(vault_data["path"])
pd = base / PLUGINS_DIR_NAME
pd.mkdir(parents=True, exist_ok=True)
return pd
# ── Manager ────────────────────────────────────────────────────────────────
class PluginManager:
"""Manages plugin lifecycle within a single plugins directory."""
def __init__(self, plugins_dir):
self.plugins_dir = Path(plugins_dir)
self.plugins_dir.mkdir(parents=True, exist_ok=True)
def _plugin_dir(self, name: str) -> Path:
return self.plugins_dir / name
# ---- install / uninstall ----
def install(self, manifest: dict, code: str) -> dict[str, Any]:
"""Install a plugin from a manifest dict + entry point source string."""
m = PluginManifest.from_dict(manifest)
plugin_dir = self._plugin_dir(m.name)
if plugin_dir.exists():
raise ValueError(f"Plugin '{m.name}' is already installed")
installed = list(self.plugins_dir.iterdir()) if self.plugins_dir.exists() else []
if installed and len(installed) >= MAX_PLUGINS_PER_VAULT:
raise ValueError(f"Maximum of {MAX_PLUGINS_PER_VAULT} plugins per vault reached")
if len(code.encode("utf-8")) > MAX_PLUGIN_SIZE:
raise ValueError(f"Plugin file exceeds {MAX_PLUGIN_SIZE} bytes")
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / MANIFEST_FILENAME).write_text(
json.dumps(m.to_dict(), indent=2), encoding="utf-8"
)
(plugin_dir / m.main).write_text(code, encoding="utf-8")
logger.info("Installed plugin '%s' v%s", m.name, m.version)
return {
"name": m.name,
"version": m.version,
"description": m.description,
"author": m.author,
"main": m.main,
"enabled": True,
}
def uninstall(self, name: str) -> None:
plugin_dir = self._plugin_dir(name)
if not plugin_dir.exists():
raise ValueError(f"Plugin '{name}' not found")
import shutil
shutil.rmtree(plugin_dir)
logger.info("Uninstalled plugin '%s'", name)
# ---- enable / disable ----
def enable(self, name: str) -> None:
plugin_dir = self._plugin_dir(name)
if not plugin_dir.exists():
raise ValueError(f"Plugin '{name}' not found")
marker = plugin_dir / ".disabled"
if marker.exists():
marker.unlink()
def disable(self, name: str) -> None:
plugin_dir = self._plugin_dir(name)
if not plugin_dir.exists():
raise ValueError(f"Plugin '{name}' not found")
(plugin_dir / ".disabled").write_text("", encoding="utf-8")
def is_disabled(self, name: str) -> bool:
return (self._plugin_dir(name) / ".disabled").exists()
# ---- listing / metadata ----
def list_plugins(self) -> list[dict[str, Any]]:
if not self.plugins_dir.exists():
return []
out = []
for d in sorted(self.plugins_dir.iterdir()):
if not d.is_dir():
continue
manifest_file = d / MANIFEST_FILENAME
if not manifest_file.exists():
continue
try:
m = PluginManifest.from_dict(json.loads(manifest_file.read_text(encoding="utf-8")))
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid plugin dir '%s': %s", d.name, exc)
continue
out.append({
"name": m.name,
"version": m.version,
"description": m.description,
"author": m.author,
"main": m.main,
"enabled": not self.is_disabled(m.name),
"hooks": m.hooks,
"permissions": m.permissions,
})
return out
def get_plugin(self, name: str) -> dict[str, Any] | None:
plugin_dir = self._plugin_dir(name)
manifest_file = plugin_dir / MANIFEST_FILENAME
if not manifest_file.exists():
return None
try:
m = PluginManifest.from_dict(json.loads(manifest_file.read_text(encoding="utf-8")))
except (json.JSONDecodeError, ValueError):
return None
return {
"manifest": m.to_dict(),
"name": m.name,
"version": m.version,
"enabled": not self.is_disabled(m.name),
}
def get_plugin_code(self, name: str, file_name: str) -> str:
plugin_dir = self._plugin_dir(name)
manifest_file = plugin_dir / MANIFEST_FILENAME
if not manifest_file.exists():
raise ValueError(f"Plugin '{name}' not found")
target = plugin_dir / file_name
# Prevent path traversal
try:
target.resolve().relative_to(plugin_dir.resolve())
except (ValueError, OSError):
raise ValueError(f"Invalid file path: {file_name}")
if not target.is_file():
raise ValueError(f"File '{file_name}' not found for plugin '{name}'")
return target.read_text(encoding="utf-8")
def get_hooks(self, name: str) -> dict[str, str]:
plugin = self.get_plugin(name)
if not plugin:
raise ValueError(f"Plugin '{name}' not found")
return plugin["manifest"].get("hooks", {})
class PluginRegistry:
"""Aggregates enabled plugins for a plugins directory."""
def get_enabled_plugins(self, plugins_dir) -> list[dict[str, Any]]:
manager = PluginManager(plugins_dir)
return [p for p in manager.list_plugins() if p["enabled"]]
def get_plugins_by_hook(self, plugins_dir, hook: str) -> list[dict[str, Any]]:
manager = PluginManager(plugins_dir)
return [
p for p in manager.list_plugins()
if p["enabled"] and hook in p.get("hooks", {})
]
def scan_vault(self, vault_name: str, vault_path) -> list[dict[str, Any]]:
"""Scan a vault directory for installed plugins (startup discovery).
Returns the list of installed-but-valid plugins so the app can log
how many are available per vault. Invalid/zombie plugin dirs are
skipped gracefully.
"""
pd = Path(vault_path) / PLUGINS_DIR_NAME
if not pd.is_dir():
return []
return PluginManager(pd).list_plugins()
# Module-level registry singleton (startup scan + runtime lookups)
_registry_singleton: PluginRegistry | None = None
def get_plugin_registry() -> PluginRegistry:
"""Return the shared PluginRegistry instance."""
global _registry_singleton
if _registry_singleton is None:
_registry_singleton = PluginRegistry()
return _registry_singleton
# ── Validation of uploads (ZIP / directory) ────────────────────────────────
def _plugin_manifest_from_member(zf: zipfile.ZipFile) -> dict[str, Any]:
try:
raw = zf.read(MANIFEST_FILENAME).decode("utf-8")
except KeyError:
raise ValueError("Plugin zip is missing plugin.json")
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise ValueError("Invalid JSON in plugin.json")
m = PluginManifest.from_dict(data)
return m.to_dict()
def _validate_plugin_zip(zip_path) -> dict[str, Any]:
"""Validate an uploaded plugin zip and return its manifest dict."""
with zipfile.ZipFile(zip_path, "r") as zf:
members = zf.namelist()
if len(members) > MAX_PLUGIN_FILES:
raise ValueError(f"Plugin contains too many files (max {MAX_PLUGIN_FILES})")
for member in members:
if member.endswith("/"):
continue
norm = member.replace("\\", "/")
clean = norm.lstrip("/")
if ".." in clean.split("/"):
raise ValueError("Path traversal detected in plugin zip")
# Reject absolute paths and any ../../ escapes
if norm.startswith("/") or "/../" in f"/{norm}" or norm.endswith("/.."):
raise ValueError("Path traversal detected in plugin zip")
manifest = _plugin_manifest_from_member(zf)
# Entry point must exist
main = manifest["main"]
if main not in members and f"{main}/" not in members:
# accept with any leading ./ or subdir normalization
found = any(m.rstrip("/") == main or m.rstrip("/") == f"./{main}" for m in members)
if not found:
raise ValueError(f"Missing entry point: {main}")
return manifest
def _validate_plugin_directory(plugin_dir) -> dict[str, Any]:
"""Validate an installed plugin directory and return its manifest dict."""
d = Path(plugin_dir)
manifest_file = d / MANIFEST_FILENAME
if not manifest_file.is_file():
raise ValueError(f"Missing {MANIFEST_FILENAME}")
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except json.JSONDecodeError:
raise ValueError(f"Invalid JSON in {MANIFEST_FILENAME}")
m = PluginManifest.from_dict(data)
if not (d / m.main).is_file():
raise ValueError(f"Missing entry point: {m.main}")
return m.to_dict()
# ── API Endpoints ──────────────────────────────────────────────────────────
@router.get("", response_model=list[dict])
async def api_list_plugins(vault: str, current_user=Depends(require_auth)):
"""List installed plugins for a vault."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
return PluginManager(plugins_dir).list_plugins()
@router.get("/template")
async def api_plugin_template():
"""Return a starter plugin template (manifest + sample code)."""
template_manifest = {
"name": "my-plugin",
"version": "1.0.0",
"description": "Describe what your plugin does",
"author": "your-name",
"main": "index.js",
"hooks": {"onFileRender": "render"},
"permissions": ["read_files", "ui_notify"],
"license": "MIT",
}
template_code = (
"// ObsiGate plugin template\n"
"export function render(ctx) {\n"
" // ctx: { path, content, extension, vault }\n"
" if (ctx.content && ctx.path.endsWith('.md')) {\n"
" ctx.content = `> Plugin: ${self.name}\\n\\n` + ctx.content;\n"
" }\n"
" return ctx;\n"
"}\n"
)
return {
"manifest": template_manifest,
"code": template_code,
}
@router.get("/{plugin_name}", response_model=dict)
async def api_get_plugin(vault: str, plugin_name: str, current_user=Depends(require_auth)):
"""Get a single plugin's metadata."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
plugin = PluginManager(plugins_dir).get_plugin(plugin_name)
if not plugin:
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
return plugin
@router.get("/{plugin_name}/hooks", response_model=dict)
async def api_plugin_hooks(vault: str, plugin_name: str, current_user=Depends(require_auth)):
"""Get the hooks a plugin registers."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
return PluginManager(plugins_dir).get_hooks(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@router.get("/{plugin_name}/code/{file_name:path}", response_model=dict)
async def api_plugin_code(
vault: str, plugin_name: str, file_name: str, current_user=Depends(require_auth)
):
"""Get a plugin source file for sandboxed execution."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
code = PluginManager(plugins_dir).get_plugin_code(plugin_name, file_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return {"name": plugin_name, "file": file_name, "code": code}
@router.post("/install")
async def api_install_plugin(
vault: str,
file: UploadFile = File(...),
current_user=Depends(require_admin),
):
"""Install a plugin from an uploaded zip (admin only)."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
content = await file.read()
if len(content) > 5_000_000: # 5 MB upload cap
raise HTTPException(status_code=413, detail="Plugin zip too large")
import io
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = Path(tmp_dir) / "plugin.zip"
try:
zip_path.write_bytes(content)
manifest = _validate_plugin_zip(zip_path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid plugin: {exc}")
# Extract to a temp dir then validate the entry point exists
plugins_dir = _vault_plugins_dir(vault)
manager = PluginManager(plugins_dir)
name = manifest["name"]
with tempfile.TemporaryDirectory() as td:
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
zf.extractall(td)
except (zipfile.BadZipFile, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid plugin zip")
extracted = _validate_plugin_directory(Path(td) / name if (Path(td) / name).is_dir() else Path(td))
code_file = manifest["main"]
with zipfile.ZipFile(io.BytesIO(content)) as zf:
code = zf.read(code_file).decode("utf-8")
try:
result = manager.install(extracted, code)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return result
@router.delete("/{plugin_name}")
async def api_uninstall_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
PluginManager(plugins_dir).uninstall(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"ok": True})
@router.post("/{plugin_name}/enable")
async def api_enable_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
PluginManager(plugins_dir).enable(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"ok": True})
@router.post("/{plugin_name}/disable")
async def api_disable_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
PluginManager(plugins_dir).disable(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"ok": True})
# ── Plugin Event Bus ────────────────────────────────────────────────────────
# Lightweight pub/sub for backend events that plugins can subscribe to.
# Used to dispatch onFileCreate, onFileDelete, onVaultMount to enabled plugins.
import time as _time
class _PluginEventBus:
"""In-memory event dispatcher for plugin lifecycle events."""
_listeners: dict[str, list] = {}
@classmethod
def subscribe(cls, event_type: str, callback) -> None:
cls._listeners.setdefault(event_type, []).append(callback)
@classmethod
async def emit(cls, event_type: str, data: dict) -> None:
for cb in cls._listeners.get(event_type, []):
try:
result = cb(data)
if hasattr(result, "__await__"):
await result
except Exception as e:
logger.warning(f"Plugin event '{event_type}' handler error: {e}")
plugin_events = _PluginEventBus
def emit_file_created(vault: str, path: str, title: str = "") -> None:
"""Called when a file is created via the API."""
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(plugin_events.emit("onFileCreate", {
"vault": vault, "path": path, "title": title,
"timestamp": _time.time(),
}))
except RuntimeError:
pass # No event loop running
def emit_file_deleted(vault: str, path: str) -> None:
"""Called when a file is deleted via the API."""
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(plugin_events.emit("onFileDelete", {
"vault": vault, "path": path,
"timestamp": _time.time(),
}))
except RuntimeError:
pass
def emit_vault_mounted(vault: str, path: str) -> None:
"""Called when a vault is loaded into the index at startup."""
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(plugin_events.emit("onVaultMount", {
"vault": vault, "path": path,
"timestamp": _time.time(),
}))
except RuntimeError:
pass
+5 -3
View File
@@ -12,9 +12,11 @@ services:
build:
context: .
args:
# VERSION est injecte par build.sh/CI via `git describe` ;
# sans variable d'env, l'image tombe sur 0.0.0-dev (fallback Dockerfile).
VERSION: ${VERSION:-0.0.0-dev}
# VERSION est injecte par build.sh/CI via `git describe`.
# En `docker compose build` direct (sans build.sh), on retombe sur le
# DERNIER TAG (v2.1.0) et non 0.0.0-dev, pour ne jamais afficher "0.0.0"
# dans le header / la boîte À propos. Packagé par backend/VERSION.
VERSION: ${VERSION:-2.1.0}
image: obsigate:latest
container_name: obsigate
user: "1000:1000"
+215
View File
@@ -0,0 +1,215 @@
# Plugin System — ObsiGate #61
## Overview
ObsiGate's plugin system enables extending the application with custom
renderers, search filters, and editor actions — all sandboxed for security.
Plugins run in a Web Worker sandbox and communicate with the main app via
structured `postMessage`. No plugin can access the DOM, localStorage, or
make network requests without explicit permission.
## Quick Start
### Installing a Plugin
1. Go to **Settings > Plugins** in the sidebar
2. Click **Install Plugin**
3. Upload a `.zip` file or a directory containing:
- `plugin.json` — the manifest
- Your main JS file (entry point specified in `main`)
### Creating a Plugin
Use the **View Template** button in Settings > Plugins to get started.
## Manifest (plugin.json)
```json
{
"name": "my-plugin",
"version": "1.0.0",
"description": "A brief description of what this plugin does",
"author": "your-name",
"main": "index.js",
"hooks": {
"onFileRender": "renderFile",
"onSearchFilter": "filterResults",
"onEditorAction": "handleAction"
},
"permissions": ["read_files"],
"min_obsigate_version": "2.1.0",
"license": "MIT",
"homepage": "https://example.com",
"repository": "https://github.com/user/plugin"
}
```
### Required Fields
| Field | Type | Description |
|---------------|--------|-------------------------------------------------|
| `name` | string | Lowercase alphanumeric + hyphens (e.g. `my-plugin`) |
| `version` | string | Semantic version (e.g. `1.0.0`, `1.0.0-beta.1`) |
| `description` | string | Brief description |
| `author` | string | Author name |
| `main` | string | Entry point file (relative to plugin root) |
### Optional Fields
| Field | Type | Default | Description |
|------------------------|--------|-------------|------------------------------------------|
| `hooks` | object | `{}` | Map of hook name → handler function name |
| `permissions` | array | `[]` | Required permissions |
| `min_obsigate_version` | string | `"2.1.0"` | Minimum ObsiGate version |
| `license` | string | `"MIT"` | License identifier |
| `homepage` | string | `null` | Plugin homepage URL |
| `repository` | string | `null` | Source repository URL |
## Available Hooks
| Hook | When it fires | Handler signature |
|-------------------|-----------------------------------|--------------------------------|
| `onFileRender` | Before a file is rendered | `(ctx) → transformed ctx` |
| `onSearchFilter` | During search result filtering | `(results) → filtered results` |
| `onEditorAction` | When editor action is triggered | `(action, state) → result` |
| `onSidebarItem` | Sidebar item is rendered | `(item) → enhanced item` |
| `onFileCreate` | After a file is created | `(file) → void` |
| `onFileDelete` | After a file is deleted | `(file) → void` |
| `onVaultMount` | When a vault is mounted | `(vault) → void` |
## Permissions
Plugins must declare the permissions they need. The sandbox enforces these
restrictions at runtime.
| Permission | Description |
|-----------------------|--------------------------------------------------|
| `read_files` | Read file contents from the vault |
| `write_files` | Write/create files in the vault |
| `read_vault_metadata` | Access vault configuration and metadata |
| `network_request` | Make HTTP requests to external services |
| `ui_notify` | Show toast notifications in the UI |
| `access_clipboard` | Read/write to the system clipboard |
## Security Model
### Sandbox Architecture
```
┌─────────────────────────────────┐
│ Main App (browser context) │
│ │
│ PluginManager │
│ ├── install/uninstall │
│ ├── enable/disable │
│ └── hook dispatch │
│ │ postMessage (C3) │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Web Worker Sandbox │ │
│ │ (no DOM, no localStorage│ │
│ │ no network by default) │ │
│ │ │ │
│ │ Plugin code executes │ │
│ │ here with structured │ │
│ │ message passing only │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
```
### Security Guarantees
- **C1**: Manifest validation (name format, semver, allowed hooks/permissions)
- **C2**: Vault isolation — plugins are scoped to a single vault
- **C3**: Web Worker sandbox — no DOM, no `localStorage`, no `importScripts`
- **C4**: ZIP validation — path traversal, file count, size limits
- **C5**: Directory validation — manifest + entry point presence
- **C6**: Permission enforcement at sandbox boundary
### Limits
- Max 50 plugins per vault
- Max 500 KB per plugin file
- Max 100 files per plugin ZIP
- No dynamic `import()` or `eval()`
## Plugin Storage
Plugins are installed under `<vault>/.obsigate-plugins/<plugin-name>/`:
```
.obsigate-plugins/
my-plugin/
plugin.json # manifest
index.js # entry point
.disabled # marker file (created when disabled)
```
## API Reference
### Backend Endpoints
| Method | Endpoint | Auth | Description |
|--------|---------------------------------|---------|--------------------------|
| GET | `/api/plugins` | require_auth | List installed plugins |
| POST | `/api/plugins/install` | require_admin | Install from ZIP |
| DELETE | `/api/plugins/{name}` | require_admin | Uninstall plugin |
| POST | `/api/plugins/{name}/enable` | require_admin | Enable plugin |
| POST | `/api/plugins/{name}/disable` | require_admin | Disable plugin |
| GET | `/api/plugins/{name}/code/{file}` | require_auth | Get plugin code |
| GET | `/api/plugins/{name}/hooks` | require_auth | Get plugin hooks |
| GET | `/api/plugins/template` | require_auth | Get plugin template |
### Frontend Module (`plugins.js`)
```javascript
import { PluginManager } from './plugins.js';
// Install
await PluginManager.installPlugin(manifest, code);
// Enable/Disable
await PluginManager.enablePlugin('my-plugin');
await PluginManager.disablePlugin('my-plugin');
// Uninstall
await PluginManager.uninstallPlugin('my-plugin');
// Get code for sandbox
const code = await PluginManager.getPluginCode('my-plugin', 'index.js');
// List
const plugins = await PluginManager.getCachedPlugins();
```
## Creating Your First Plugin
1. Create a directory with `plugin.json`:
```json
{
"name": "hello-world",
"version": "1.0.0",
"description": "My first ObsiGate plugin",
"author": "you",
"main": "index.js",
"hooks": {
"onFileRender": "render"
},
"permissions": ["read_files", "ui_notify"]
}
```
2. Create `index.js`:
```javascript
export function render(ctx) {
// Add a custom header to rendered markdown
if (ctx.content && ctx.path.endsWith('.md')) {
ctx.content = `> 📝 Plugin: hello-world\n\n${ctx.content}`;
}
return ctx;
}
```
3. Zip the directory and install via Settings > Plugins > Install Plugin.
+16 -10
View File
@@ -160,17 +160,23 @@
- [x] UI indicateur : badge « Hors-ligne » + compteur de modifications en attente
- [x] Gestion des conflits : détection et résolution manuelle (choix version locale vs serveur)
### 61. Plugins système — Extensions utilisateur
- **Effort :** 4-5 jours | **Impact :** 🟢
### 61. Plugins système — Extensions utilisateur ✅
- **Effort :** 4-5 jours | **Impact :** 🟢 | **Statut :** ✅ Livré (backend + frontend + tests + docs)
- **Description :** Système de plugins permettant aux utilisateurs d'étendre ObsiGate avec des renderers personnalisés, des opérateurs de recherche, et des hooks d'UI. Inspiré du modèle de plugins Obsidian.
- **Sous-tâches :**
- [ ] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
- [ ] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`
- [ ] Sandbox d'exécution : Web Worker isolé pour le code plugin
- [ ] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller)
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index)
- [ ] Hot-reload : activation/désactivation sans rechargement de page
- [ ] Sécurité : manifest de permissions, validation de signature, CSP restrictif
- [x] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
- [x] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`, `onFileCreate`, `onFileDelete`, `onVaultMount`
- [x] Sandbox d'exécution : Web Worker isolé pour le code plugin (blob URL, postMessage structuré, CSP sans importScripts)
- [x] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller, template, viewer)
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index) — *backlog*
- [x] Hot-reload : activation/désactivation sans rechargement de page (marker `.disabled`)
- [x] Sécurité : manifest de permissions, validation path-traversal, CSP restrictif
- **Livré :**
- Backend `backend/plugins.py` — validation manifest (name regex, semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle complet, validation ZIP (path traversal, limite 100 fichiers, 500KB/fichier), 9 endpoints `/api/plugins/*` (admin-gated pour install/uninstall/enable/disable), template API.
- Frontend `frontend/js/plugins.js` — PluginManager, sandbox Web Worker (code via blob URL, protocole postMessage structuré), UI Settings > Plugins, hooks dispatch (`executeHook`/`onFileRender`/`onSearchFilter`/…).
- Tests : `tests/test_plugins.py` (44) + `tests/frontend/plugins.test.mjs` (21) — validation, lifecycle, ZIP/dir sécurité, protocole sandbox, isolation DOM/CSP.
- Docs : `docs/PLUGINS.md`.
- **En backlog (non retenu) :** dépôt communautaire (index JSON), signature de code des plugins.
### 62. Collaboration temps réel — Édition simultanée
- **Effort :** 5-7 jours | **Impact :** 🟢
@@ -893,7 +899,7 @@
|---|---|---|
| ✅ Complété | #1 → #59, #63-66, #71, #74, #75, #76 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 71 admin, 74 PDF, 76 BooksLM) | ~75 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
| ⚪ P3 restant | #61 Plugins système (4-5j) · #62 Collaboration Yjs (5-7j) · #78 Excalidraw finitions (B5 recherche, C8, F3 E2E, BUG-002) (~1-1.5j) | ~10-13.5 jours |
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #78 Excalidraw finitions (B5 recherche, C8, F3 E2E, BUG-002) (~1-1.5j) | ~6-8.5 jours |
| ⚪ P4 restant | #67 Push (2j) · #68 Health enrichi (1j) · #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #72 OpenAPI (1-2j) · #73 Sync (6-8j) | 16-21 jours |
| **Total restant** | **9 items + finitions** | **~28-37 jours** |
+15
View File
@@ -1450,10 +1450,13 @@
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
</ul>
<li>
<a href="#help-plugins" class="help-nav-link"
</nav>
<div class="help-content" id="config-scroll">
<div class="config-content">
@@ -2204,6 +2207,18 @@
</div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
<div id="plugins-settings-container"></div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
+2
View File
@@ -22,6 +22,7 @@ import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
import PaneManager from './pane-manager.js';
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
import { initPush } from './push.js';
import { initPlugins } from './plugins.js';
// =========================================================================
// Initialization — mirrors the original app.js init() ordering
@@ -70,6 +71,7 @@ async function init() {
initCommandPalette();
initMobileToolbar();
initPush();
initPlugins();
if (authOk) {
Legacy.initSyncStatus();
+13 -6
View File
@@ -1652,21 +1652,28 @@ function initAboutModal() {
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
function populateAbout() {
// Live stats + version from health endpoint (single source of truth)
// Live stats + version from health endpoint (single source of truth).
// Fallback silencieux : si le fetch échoue, on garde la version pré-chargée
// (window.__OBSIGATE_VERSION) au lieu d'un "—" trompeur.
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
var fEl = document.getElementById('about-stat-files');
var vEl = document.getElementById('about-stat-vaults');
if (fEl) fEl.textContent = d.total_files || '—';
if (vEl) vEl.textContent = d.vaults || '—';
if (fEl && d.total_files) fEl.textContent = d.total_files;
if (vEl && d.vaults) vEl.textContent = d.vaults;
// Clean x.y.z version (not hardcoded) + git detail
var versionEl = document.getElementById('about-version');
if (versionEl && d.version) versionEl.textContent = d.version;
if (versionEl) {
versionEl.textContent = (d && d.version) || window.__OBSIGATE_VERSION || '—';
}
var commitEl = document.getElementById('about-commit');
if (commitEl) commitEl.textContent = d.git_describe || d.git_commit || '—';
if (commitEl) commitEl.textContent = (d && d.git_describe) || (d && d.git_commit) || '—';
})
.catch(function() {});
.catch(function() {
var versionEl = document.getElementById('about-version');
if (versionEl) versionEl.textContent = window.__OBSIGATE_VERSION || '—';
});
// Build date (today)
var now = new Date();
+568
View File
@@ -0,0 +1,568 @@
/* ObsiGate — Plugin Manager (Frontend)
* Handles plugin lifecycle, UI, and sandboxed execution via Web Worker.
*/
import { api } from './auth.js';
import { showToast } from './ui.js';
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
// ── Plugin Registry (in-memory cache) ─────────────────────────────────────
let _pluginsCache = new Map();
let _worker = null;
let _pendingCalls = new Map();
let _callId = 0;
// ── Worker Management ────────────────────────────────────────────────────
function getOrCreateWorker() {
if (!_worker) {
// Create worker from blob to avoid separate file (simpler deployment)
// Using string concatenation to avoid nested template literal issues
const workerCode =
"const pluginSandbox = {};\n" +
"const handlers = {};\n\n" +
"// Safe globals for plugins\n" +
"const safeGlobals = {\n" +
" console: {\n" +
" log: (...args) => self.postMessage({ type: 'log', args }),\n" +
" warn: (...args) => self.postMessage({ type: 'warn', args }),\n" +
" error: (...args) => self.postMessage({ type: 'error', args })\n" +
" },\n" +
" fetch: async (url, options) => {\n" +
" const response = await fetch(url, options);\n" +
" const data = await response.json();\n" +
" return data;\n" +
" },\n" +
" setTimeout,\n" +
" clearTimeout,\n" +
" Promise,\n" +
" JSON,\n" +
" Math,\n" +
" Date,\n" +
" Object,\n" +
" Array,\n" +
" String,\n" +
" Number,\n" +
" Boolean,\n" +
" RegExp,\n" +
" Error,\n" +
" Map,\n" +
" Set,\n" +
" WeakMap,\n" +
" WeakSet,\n" +
" URL,\n" +
" URLSearchParams,\n" +
" Headers,\n" +
" Request,\n" +
" Response,\n" +
" FormData,\n" +
" Blob,\n" +
" File,\n" +
" atob,\n" +
" btoa,\n" +
" encodeURIComponent,\n" +
" decodeURIComponent,\n" +
" parseInt,\n" +
" parseFloat,\n" +
" isNaN,\n" +
" isFinite\n" +
"};\n\n" +
"// Message handler\n" +
"self.onmessage = async (e) => {\n" +
" const { type, payload, callId } = e.data;\n\n" +
" try {\n" +
" if (type === 'loadPlugin') {\n" +
" const { code, name, permissions } = payload;\n\n" +
" const sandbox = { ...safeGlobals };\n" +
" const pluginModule = { exports: {} };\n\n" +
" const fn = new Function(\n" +
" 'module', 'exports', 'require', 'globals',\n" +
" code\n" +
" );\n" +
" fn(pluginModule, pluginModule.exports, (mod) => {\n" +
" throw new Error('require() not allowed in plugins');\n" +
" }, sandbox);\n\n" +
" for (const [hook, handlerName] of Object.entries(sandbox.handlers || {})) {\n" +
" if (!handlers[hook]) handlers[hook] = [];\n" +
" handlers[hook].push({ name, handler: sandbox[handlerName], permissions });\n" +
" }\n\n" +
" self.postMessage({ type: 'loaded', callId, plugin: name });\n" +
" }\n" +
" else if (type === 'executeHook') {\n" +
" const { hook, context, plugins: pluginList } = payload;\n" +
" const results = [];\n\n" +
" for (const p of pluginList) {\n" +
" const hookHandlers = handlers[hook]?.filter(h => h.name === p.name) || [];\n" +
" for (const h of hookHandlers) {\n" +
" try {\n" +
" const missing = h.permissions?.filter(p => !payload.permissions?.includes(p)) || [];\n" +
" if (missing.length > 0) {\n" +
" results.push({ plugin: p.name, success: false, error: 'Missing permissions: ' + missing.join(', ') });\n" +
" continue;\n" +
" }\n\n" +
" const result = await h.handler(context);\n" +
" results.push({ plugin: p.name, success: true, data: result });\n" +
" } catch (err) {\n" +
" results.push({ plugin: p.name, success: false, error: err.message });\n" +
" }\n" +
" }\n" +
" }\n\n" +
" self.postMessage({ type: 'hookResult', callId, results });\n" +
" }\n" +
" else if (type === 'unloadPlugin') {\n" +
" const { name } = payload;\n" +
" for (const hook of Object.keys(handlers)) {\n" +
" handlers[hook] = handlers[hook].filter(h => h.name !== name);\n" +
" }\n" +
" self.postMessage({ type: 'unloaded', callId });\n" +
" }\n" +
" } catch (err) {\n" +
" self.postMessage({ type: 'error', callId, error: err.message });\n" +
" }\n" +
"};\n";
const blob = new Blob([workerCode], { type: 'application/javascript' });
_worker = new Worker(URL.createObjectURL(blob));
_worker.onmessage = (e) => {
const { type, callId, plugin, result, results, error } = e.data;
const pending = _pendingCalls.get(callId);
if (!pending) return;
clearTimeout(pending.timeout);
_pendingCalls.delete(callId);
if (type === 'error' || type === 'loaded' || type === 'unloaded') {
if (error) pending.reject(new Error(error));
else pending.resolve({ success: true });
} else if (type === 'hookResult') {
pending.resolve(results);
}
};
_worker.onerror = (err) => {
console.error('[PluginWorker] Error:', err);
for (const [, pending] of _pendingCalls) {
clearTimeout(pending.timeout);
pending.reject(new Error('Worker error: ' + err.message));
}
_pendingCalls.clear();
_worker = null;
};
}
return _worker;
}
function callWorker(type, payload, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const worker = getOrCreateWorker();
const id = ++_callId;
const timeout = setTimeout(() => {
_pendingCalls.delete(id);
reject(new Error('Plugin worker timeout'));
}, timeoutMs);
_pendingCalls.set(id, { resolve, reject, timeout });
worker.postMessage({ type, payload, callId: id });
});
}
// ── Public API ────────────────────────────────────────────────────────────
export async function loadPlugins(vault) {
try {
const plugins = await api('/api/plugins?vault=' + encodeURIComponent(vault));
_pluginsCache.set(vault, plugins);
return plugins;
} catch (err) {
console.error('Failed to load plugins:', err);
return [];
}
}
export function getCachedPlugins(vault) {
return _pluginsCache.get(vault) || [];
}
export async function installPlugin(vault, manifest, code) {
const plugin = await api('/api/plugins?vault=' + encodeURIComponent(vault), {
method: 'POST',
body: JSON.stringify({ manifest, code })
});
await loadPlugins(vault);
return plugin;
}
export async function uninstallPlugin(vault, name) {
await api('/api/plugins/' + encodeURIComponent(name) + '?vault=' + encodeURIComponent(vault), {
method: 'DELETE'
});
await loadPlugins(vault);
}
export async function enablePlugin(vault, name) {
const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/enable?vault=' + encodeURIComponent(vault), {
method: 'POST'
});
await loadPlugins(vault);
return plugin;
}
export async function disablePlugin(vault, name) {
const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/disable?vault=' + encodeURIComponent(vault), {
method: 'POST'
});
await loadPlugins(vault);
return plugin;
}
export async function getPluginCode(vault, name) {
const { code } = await api('/api/plugins/' + encodeURIComponent(name) + '/code?vault=' + encodeURIComponent(vault));
return code;
}
export async function validateManifest(manifest) {
return api('/api/plugins/validate-manifest', {
method: 'POST',
body: JSON.stringify(manifest)
});
}
export async function getPluginTemplate() {
return api('/api/plugins/template');
}
// ── Hook Execution (for other modules to call) ────────────────────────────
export async function executeHook(vault, hook, context) {
const plugins = getCachedPlugins(vault).filter(p => p.enabled && p.hooks?.includes(hook));
if (plugins.length === 0) return [];
const pluginList = plugins.map(p => ({
name: p.name,
handler: p.hooks?.[hook] || '',
permissions: p.permissions || []
})).filter(p => p.handler);
if (pluginList.length === 0) return [];
try {
const results = await callWorker('executeHook', {
hook,
context,
plugins: pluginList,
permissions: pluginList.flatMap(p => p.permissions)
});
return results;
} catch (err) {
console.error('Hook ' + hook + ' execution failed:', err);
return pluginList.map(p => ({
plugin: p.name,
success: false,
error: err.message
}));
}
}
// Convenience methods for specific hooks
export async function onFileRender(vault, fileContent, filePath) {
const results = await executeHook(vault, 'onFileRender', { fileContent, filePath, vault });
let replaced = null;
const append = [];
for (const r of results) {
if (!r.success) continue;
const d = r.data;
if (typeof d === 'string') {
replaced = d;
} else if (d && typeof d === 'object') {
if (typeof d.content === 'string') replaced = d.content;
if (typeof d.append === 'string') append.push(d.append);
if (typeof d.html === 'string') append.push(d.html);
}
}
return { replace: replaced, append };
}
export async function onSearchFilter(vault, query, results) {
const hookResults = await executeHook(vault, 'onSearchFilter', { query, results, vault });
for (const r of hookResults) {
if (r.success && Array.isArray(r.data)) return r.data;
}
return results;
}
export async function onEditorAction(vault, action, editorState) {
const results = await executeHook(vault, 'onEditorAction', { action, editorState, vault });
return results.some(r => r.success && r.data?.handled === true);
}
export async function onSidebarItem(vault, itemType, itemPath) {
const results = await executeHook(vault, 'onSidebarItem', { itemType, itemPath, vault });
const items = [];
for (const r of results) {
if (r.success && Array.isArray(r.data)) items.push(...r.data);
}
return items;
}
export async function onFileCreate(vault, filePath, content) {
await executeHook(vault, 'onFileCreate', { filePath, content, vault });
}
export async function onFileDelete(vault, filePath) {
const results = await executeHook(vault, 'onFileDelete', { filePath, vault });
return results.every(r => r.success && r.data?.allow !== false);
}
export async function onVaultMount(vault) {
await executeHook(vault, 'onVaultMount', { vault });
}
// ── UI: Plugins Settings Page ────────────────────────────────────────────
export async function renderPluginsSettings(vault, container) {
const plugins = await loadPlugins(vault);
container.innerHTML =
'<div class="plugins-settings">' +
'<div class="plugins-header">' +
'<h2 data-i18n="plugins.title">🧩 Plugins</h2>' +
'<p class="config-description" data-i18n="plugins.description">' +
'Extend ObsiGate with custom renderers, search filters, and editor actions.' +
'</p>' +
'</div>' +
'<div class="plugins-toolbar">' +
'<button class="btn btn-primary" id="plugin-install-btn" data-i18n="plugins.install">Install Plugin</button>' +
'<button class="btn btn-secondary" id="plugin-template-btn" data-i18n="plugins.template">View Template</button>' +
'</div>' +
'<div id="plugins-list" class="plugins-list">' +
(plugins.length === 0
? '<div class="config-description" data-i18n="plugins.none">No plugins installed.</div>'
: plugins.map(p =>
'<div class="plugin-item ' + (p.enabled ? '' : 'disabled') + '" data-name="' + p.name + '">' +
'<div class="plugin-info">' +
'<div class="plugin-name">' +
'<strong>' + p.name + '</strong> v' + p.version +
'<span class="plugin-author">by ' + p.author + '</span>' +
'</div>' +
'<div class="plugin-description">' + p.description + '</div>' +
'<div class="plugin-meta">' +
'<span class="plugin-hooks">' + (p.hooks?.join(', ') || 'no hooks') + '</span>' +
'<span class="plugin-perms">' + (p.permissions?.join(', ') || 'no permissions') + '</span>' +
'</div>' +
'</div>' +
'<div class="plugin-actions">' +
'<button class="btn btn-sm ' + (p.enabled ? 'btn-warning' : 'btn-success') + '"' +
' data-action="' + (p.enabled ? 'disable' : 'enable') + '"' +
' data-name="' + p.name + '"' +
' data-i18n="' + (p.enabled ? 'plugins.disable' : 'plugins.enable') + '">' +
(p.enabled ? 'Disable' : 'Enable') +
'</button>' +
'<button class="btn btn-sm btn-secondary" data-action="code" data-name="' + p.name + '" data-i18n="plugins.view_code">Code</button>' +
'<button class="btn btn-sm btn-danger" data-action="uninstall" data-name="' + p.name + '" data-i18n="plugins.uninstall">Uninstall</button>' +
'</div>' +
'</div>'
).join('')) +
'</div>' +
'</div>';
container.querySelector('#plugin-install-btn')?.addEventListener('click', () => openInstallModal(vault));
container.querySelector('#plugin-template-btn')?.addEventListener('click', () => openTemplateModal());
container.querySelectorAll('.plugin-actions button').forEach(btn => {
btn.addEventListener('click', async (e) => {
const action = btn.dataset.action;
const name = btn.dataset.name;
try {
if (action === 'enable') {
await enablePlugin(vault, name);
showToast(t('plugins.enabled', { name }), 'success');
} else if (action === 'disable') {
await disablePlugin(vault, name);
showToast(t('plugins.disabled', { name }), 'success');
} else if (action === 'uninstall') {
if (confirm(t('plugins.confirm_uninstall', { name }))) {
await uninstallPlugin(vault, name);
showToast(t('plugins.uninstalled', { name }), 'success');
}
} else if (action === 'code') {
const code = await getPluginCode(vault, name);
openCodeModal(name, code);
}
await renderPluginsSettings(vault, container);
} catch (err) {
showToast(err.message, 'error');
}
});
});
safeCreateIcons();
}
function openInstallModal(vault) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.install">Install Plugin</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest (plugin.json)</label>' +
'<textarea id="plugin-manifest-json" class="modal-textarea" rows="15" spellcheck="false"' +
' placeholder=\'{"name":"my-plugin","version":"1.0.0","description":"...","author":"You","main":"index.js","hooks":{"onFileRender":"renderFile"},"permissions":["read_files"]}\'></textarea>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Plugin Code (main file)</label>' +
'<textarea id="plugin-code" class="modal-textarea" rows="20" spellcheck="false"' +
' placeholder="// Your plugin code here\\nexport async function renderFile(context) { ... }"></textarea>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-secondary modal-cancel" data-i18n="common.cancel">Cancel</button>' +
'<button class="btn btn-primary" id="plugin-install-confirm" data-i18n="plugins.install">Install</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.querySelector('.modal-cancel')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
modal.querySelector('#plugin-install-confirm')?.addEventListener('click', async () => {
const manifestText = modal.querySelector('#plugin-manifest-json').value;
const code = modal.querySelector('#plugin-code').value;
try {
const manifest = JSON.parse(manifestText);
const validation = await validateManifest(manifest);
if (!validation.valid) throw new Error(validation.error || 'Invalid manifest');
await installPlugin(vault, manifest, code);
showToast(t('plugins.installed', { name: manifest.name }), 'success');
modal.remove();
const container = document.getElementById('cfg-plugins');
if (container) await renderPluginsSettings(vault, container);
} catch (err) {
showToast(err.message, 'error');
}
});
}
function openTemplateModal() {
getPluginTemplate().then(template => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.template">Plugin Template</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest</label>' +
'<pre class="code-block" style="max-height: 200px; overflow: auto;">' + JSON.stringify(template.manifest, null, 2) + '</pre>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Code</label>' +
'<pre class="code-block" style="max-height: 300px; overflow: auto;">' + template.code + '</pre>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'<button class="btn btn-secondary" onclick="copyTemplate()">Copy to Clipboard</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
window.copyTemplate = () => {
navigator.clipboard.writeText(JSON.stringify(template, null, 2));
showToast('Template copied to clipboard', 'success');
};
});
}
function openCodeModal(name, code) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 800px;">' +
'<div class="modal-header">' +
'<h3>' + name + ' — Source Code</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
function escapeHtml(text) {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ── Auto-init when settings page loads ────────────────────────────────────
document.addEventListener('click', (e) => {
const link = e.target.closest('[href="#cfg-plugins"]');
if (link) {
setTimeout(() => {
const container = document.getElementById('cfg-plugins');
if (container && !container.dataset.pluginsLoaded) {
container.dataset.pluginsLoaded = 'true';
const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault;
if (vault) renderPluginsSettings(vault, container);
}
}, 100);
}
});
// Export for other modules
export const PluginManager = {
loadPlugins,
getCachedPlugins,
installPlugin,
uninstallPlugin,
enablePlugin,
disablePlugin,
getPluginCode,
validateManifest,
getPluginTemplate,
executeHook,
onFileRender,
onSearchFilter,
onEditorAction,
onSidebarItem,
onFileCreate,
onFileDelete,
onVaultMount,
renderPluginsSettings,
};
// Initialize plugins system
export async function initPlugins() {
// Pre-load plugins for current vault if available
const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault;
if (vault) {
await loadPlugins(vault);
}
}
+3
View File
@@ -8,6 +8,7 @@ import { _getEffective } from './config.js';
import { TabManager, showToast } from './ui.js';
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
import { t } from './i18n.js';
import { onSearchFilter } from './plugins.js';
// ---------------------------------------------------------------------------
// Search History Service (localStorage, LIFO, max 50, dedup)
// ---------------------------------------------------------------------------
@@ -802,6 +803,8 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
if (searchId !== state.currentSearchId) return;
state.advancedSearchTotal = data.total;
state.advancedSearchOffset = ofs;
// Plugin hook: filter search results
data.results = await onSearchFilter(state.vault || "default", data.results);
renderAdvancedSearchResults(data, query, tagFilter);
} catch (err) {
clearTimeout(timeoutId);
+24
View File
@@ -12,6 +12,7 @@ import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, D
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -877,6 +878,29 @@ export function renderFile(data) {
// Initialize outline/TOC for this document
OutlineManager.init();
// Plugin hooks: onFileRender — allow plugins to transform the rendered
// content or append custom widgets beneath it. Async + non-blocking so a
// slow/failed plugin never delays the normal file render.
if (data.content && data.is_markdown) {
onFileRender(data.vault, data.content, data.path).then((fr) => {
if (!fr) return;
if (typeof fr.replace === 'string' && fr.replace !== data.content) {
const rendered = document.getElementById('file-rendered-content');
if (rendered) rendered.innerHTML = fr.replace;
}
if (Array.isArray(fr.append) && fr.append.length > 0) {
const out = el('div', { class: 'plugin-render-output' });
fr.append.forEach((html) => {
const w = el('div', { class: 'plugin-widget' });
w.innerHTML = html;
out.appendChild(w);
});
area.appendChild(out);
safeCreateIcons();
}
}).catch(() => { /* plugins must never break rendering */ });
}
}
+31 -1
View File
@@ -460,6 +460,7 @@
"config.section_watcher": "Auto sync",
"config.section_webhooks": "🔗 Webhooks",
"config.section_push": "🔔 Push notifications",
"config.section_plugins": "🧩 Plugins",
"config.section_workflow-recommande": "Recommended workflow",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Server config",
@@ -486,6 +487,8 @@
"config.webhook_url_placeholder": "URL",
"config.webhooks": "Webhooks",
"config.webhooks_desc": "HTTP notifications to external services on file changes.",
"config.plugins": "Plugins",
"config.plugins_desc": "Manage installed plugins for this vault.",
"config.workers_label": "Search workers",
"confirm.delete_dir": "Delete this folder and all its contents?",
"confirm.delete_dir_named": "Delete \"{path}\" and ALL its contents?",
@@ -1072,6 +1075,7 @@
"help.nav_tags": "🏷️ Tags",
"help.nav_tips": "💡 Tips",
"help.nav_webhooks": "🪝 Webhooks",
"help.nav_plugins": "🧩 Plugins",
"help.new_file": "New file",
"help.new_folder": "New folder",
"help.nights": "sleepless nights 😅",
@@ -1338,6 +1342,7 @@
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
@@ -1614,5 +1619,30 @@
"mfa.webauthn_prompt": "Present your security key or confirm with Windows Hello.",
"mfa.webauthn_btn": "Verify with my key",
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
"mfa.webauthn_no_key": "No security key registered for this account."
"mfa.webauthn_no_key": "No security key registered for this account.",
"plugins.title": "🧩 Plugins",
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
"plugins.install": "Install Plugin",
"plugins.template": "View Template",
"plugins.none": "No plugins installed.",
"plugins.manifest_json": "Manifest (plugin.json)",
"plugins.code": "Plugin Code (main file)",
"plugins.view_code": "View Code",
"plugins.uninstall": "Uninstall",
"plugins.enable": "Enable",
"plugins.disable": "Disable",
"plugins.enabled": "Plugin '{name}' enabled.",
"plugins.disabled": "Plugin '{name}' disabled.",
"plugins.installed": "Plugin '{name}' installed successfully.",
"plugins.uninstalled": "Plugin '{name}' uninstalled.",
"plugins.confirm_uninstall": "Uninstall plugin '{name}'? This cannot be undone.",
"help.plugins_title": "🧩 Plugins",
"help.plugins_intro": "ObsiGate supports a plugin system to extend its functionality. Plugins can add custom renderers, search filters, editor actions, and UI elements.",
"help.plugins_install": "Installation",
"help.plugins_manage": "Management",
"help.plugins_develop": "Development",
"help.plugins_dev_intro": "A plugin is a folder or ZIP containing:",
"help.plugins_hooks_desc": "Available hooks: <code>onFileRender</code>, <code>onSearchFilter</code>, <code>onEditorAction</code>, <code>onSidebarItem</code>, <code>onFileCreate</code>, <code>onFileDelete</code>, <code>onVaultMount</code>.",
"help.plugins_security": "<strong>Security:</strong> each plugin runs in an isolated Web Worker sandbox, with no DOM, localStorage, or network access unless explicitly granted via permissions.",
"help.plugins_template": "Use the <strong>Template</strong> button in Settings > Plugins to generate a valid starter plugin."
}
+31 -1
View File
@@ -460,6 +460,7 @@
"config.section_watcher": "Synchronisation automatique",
"config.section_webhooks": "🪝 Webhooks",
"config.section_push": "🔔 Notifications push",
"config.section_plugins": "🧩 Plugins",
"config.section_workflow-recommande": "Workflow recommandé",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Configuration serveur",
@@ -486,6 +487,8 @@
"config.webhook_url_placeholder": "URL",
"config.webhooks": "Webhooks",
"config.webhooks_desc": "Notifications HTTP vers des services externes lors des changements de fichiers.",
"config.plugins": "Plugins",
"config.plugins_desc": "Gérer les plugins installés pour ce vault.",
"config.workers_label": "Workers de recherche",
"confirm.delete_dir": "Supprimer ce dossier et tout son contenu ?",
"confirm.delete_dir_named": "Supprimer \"{path}\" et TOUT son contenu ?",
@@ -1072,6 +1075,7 @@
"help.nav_tags": "🏷️ Tags",
"help.nav_tips": "💡 Astuces",
"help.nav_webhooks": "🪝 Webhooks",
"help.nav_plugins": "🧩 Plugins",
"help.new_file": "Nouveau fichier",
"help.new_folder": "Nouveau dossier",
"help.nights": "nuits blanches 😅",
@@ -1338,6 +1342,7 @@
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.themes": "🎨 Thèmes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Lien copié !",
"share.copy_link": "Copier le lien",
"share.create": "Créer un lien de partage",
@@ -1614,5 +1619,30 @@
"mfa.webauthn_prompt": "Présentez votre clé de sécurité ou confirmez avec Windows Hello.",
"mfa.webauthn_btn": "Valider avec ma clé",
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte."
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
"plugins.title": "🧩 Plugins",
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
"plugins.install": "Installer le plugin",
"plugins.template": "Voir le modèle",
"plugins.none": "Aucun plugin installé.",
"plugins.manifest_json": "Manifeste (plugin.json)",
"plugins.code": "Code du plugin (fichier principal)",
"plugins.view_code": "Voir le code",
"plugins.uninstall": "Désinstaller",
"plugins.enable": "Activer",
"plugins.disable": "Désactiver",
"plugins.enabled": "Plugin '{name}' activé.",
"plugins.disabled": "Plugin '{name}' désactivé.",
"plugins.installed": "Plugin '{name}' installé avec succès.",
"plugins.uninstalled": "Plugin '{name}' désinstallé.",
"plugins.confirm_uninstall": "Désinstaller le plugin '{name}' ? Cette action est irréversible.",
"help.plugins_title": "🧩 Plugins",
"help.plugins_intro": "ObsiGate supporte un système de plugins pour étendre ses fonctionnalités. Les plugins peuvent ajouter des renderers personnalisés, des filtres de recherche, des actions d'éditeur, et des éléments d'interface.",
"help.plugins_install": "Installation",
"help.plugins_manage": "Gestion",
"help.plugins_develop": "Développement",
"help.plugins_dev_intro": "Un plugin est un dossier ou ZIP contenant :",
"help.plugins_hooks_desc": "Hooks disponibles : <code>onFileRender</code>, <code>onSearchFilter</code>, <code>onEditorAction</code>, <code>onSidebarItem</code>, <code>onFileCreate</code>, <code>onFileDelete</code>, <code>onVaultMount</code>.",
"help.plugins_security": "<strong>Sécurité :</strong> chaque plugin s'exécute dans un Web Worker isolé (sandbox), sans accès au DOM, localStorage, ni réseau sauf permissions explicites.",
"help.plugins_template": "Utilisez le bouton <strong>Template</strong> dans Paramètres > Plugins pour générer un plugin starter valide."
}
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM integration tests for Plugin Manager (ROADMAP #61).
*
* Tests the plugin sandbox worker communication, plugin lifecycle UI,
* and security model (CSP, sandbox iframe).
*
* Usage: node tests/frontend/plugins.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="plugins-list"></div>
<div id="plugin-viewer" class="hidden">
<div id="plugin-viewer-title"></div>
<pre id="plugin-viewer-code"></pre>
<pre id="plugin-viewer-manifest"></pre>
</div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.MessageEvent = w.MessageEvent;
globalThis.Worker = class MockWorker {
constructor(url) {
this.url = url;
this.onmessage = null;
this.onerror = null;
this._handlers = {};
}
postMessage(data) {
this._lastMessage = data;
}
addEventListener(evt, fn) {
this._handlers[evt] = fn;
}
terminate() {}
};
globalThis.URL = w.URL;
globalThis.Blob = w.Blob;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
// Stub API and toast modules (imported by plugins.js)
globalThis.__plugins_api_stubs = {};
globalThis.__toast_calls = [];
// ── Minimal in-memory test implementations ───────────────────────────────────
// Instead of importing plugins.js directly (ES module + DOM dependencies),
// we replicate core logic and test it.
let passed = 0;
let failed = 0;
const failures = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(` ✅ ${name}`);
} catch (e) {
failed++;
failures.push({ name, error: e.message });
console.log(` ❌ ${name}: ${e.message}`);
}
}
// ── Sandbox Worker Security ──────────────────────────────────────────────────
console.log("\n🔒 C3 — Sandbox Worker Security\n");
test("Worker created with blob URL (no filesystem access)", () => {
const fakeCode = "self.onmessage = function(e) { self.postMessage({type:'pong'}); }";
// JSDOM doesn't implement createObjectURL; stub it to prove we create a blob: URL
const origCreate = w.URL.createObjectURL;
w.URL.createObjectURL = (blob) => `blob:mock-${Math.random().toString(36).slice(2)}`;
try {
const blob = new w.Blob([fakeCode], { type: "application/javascript" });
const url = w.URL.createObjectURL(blob);
assert.ok(url.startsWith("blob:"), "Worker URL should be a blob URL");
const worker = new Worker(url);
assert.ok(worker, "Worker should be constructible");
} finally {
w.URL.createObjectURL = origCreate;
}
});
test("Worker message protocol: install request contains manifest + code", () => {
const worker = new Worker("blob:test");
const msg = {
type: "install",
manifest: { name: "test", version: "1.0.0" },
code: "export default {}",
};
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: enable request", () => {
const worker = new Worker("blob:test");
const msg = { type: "enable", plugin: "test" };
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: disable request", () => {
const worker = new Worker("blob:test");
const msg = { type: "disable", plugin: "test" };
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: uninstall request", () => {
const worker = new Worker("blob:test");
const msg = { type: "uninstall", plugin: "test" };
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: hook execution", () => {
const worker = new Worker("blob:test");
const msg = {
type: "execute",
hook: "onFileRender",
data: { content: "# Hello", path: "test.md" },
};
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker terminate prevents further messages", () => {
const worker = new Worker("blob:test");
worker.postMessage({ type: "test" });
worker.terminate();
// After terminate, onmessage should not fire
worker.onmessage = () => {
throw new Error("Should not fire after terminate");
};
});
// ── Plugin Manifest Validation (frontend mirror) ────────────────────────────
console.log("\n📋 B5 — Manifest Validation (frontend)\n");
function validateManifest(data) {
const required = ["name", "version", "description", "author", "main"];
for (const f of required) {
if (!data[f]) throw new Error(`Missing required field: ${f}`);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(data.name) && data.name.length > 1) {
throw new Error("Plugin name must be lowercase alphanumeric with hyphens");
}
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/.test(data.version)) {
throw new Error("Version must be semantic version (e.g., '1.0.0')");
}
return true;
}
test("Valid manifest passes frontend validation", () => {
assert.ok(validateManifest({
name: "my-plugin", version: "1.0.0",
description: "d", author: "a", main: "index.js",
}));
});
test("Missing name fails", () => {
assert.throws(() => validateManifest({
version: "1.0.0", description: "d", author: "a", main: "index.js",
}), /Missing required field: name/);
});
test("Uppercase name fails", () => {
assert.throws(() => validateManifest({
name: "MyPlugin", version: "1.0.0",
description: "d", author: "a", main: "index.js",
}), /lowercase alphanumeric/);
});
test("Bad version fails", () => {
assert.throws(() => validateManifest({
name: "ok", version: "not-a-version",
description: "d", author: "a", main: "index.js",
}), /semantic version/);
});
test("Valid single-char name passes", () => {
assert.ok(validateManifest({
name: "x", version: "0.0.1",
description: "d", author: "a", main: "x.js",
}));
});
test("Version with pre-release tag passes", () => {
assert.ok(validateManifest({
name: "ok", version: "2.0.0-beta.1",
description: "d", author: "a", main: "index.js",
}));
});
// ── Plugin Manager State Logic ───────────────────────────────────────────────
console.log("\n🧩 B6 — Plugin Manager Lifecycle\n");
function createPluginManager() {
const plugins = new Map();
const disabled = new Set();
return {
install(manifest, code) {
if (plugins.has(manifest.name)) throw new Error("Already installed");
plugins.set(manifest.name, { manifest, code, enabled: true });
disabled.delete(manifest.name);
return { name: manifest.name, version: manifest.version, enabled: true };
},
uninstall(name) {
if (!plugins.has(name)) throw new Error("Not found");
plugins.delete(name);
disabled.delete(name);
},
enable(name) {
if (!plugins.has(name)) throw new Error("Not found");
disabled.delete(name);
plugins.get(name).enabled = true;
},
disable(name) {
if (!plugins.has(name)) throw new Error("Not found");
disabled.add(name);
plugins.get(name).enabled = false;
},
list() {
return Array.from(plugins.entries()).map(([_, p]) => ({
name: p.manifest.name,
version: p.manifest.version,
enabled: !disabled.has(p.manifest.name),
}));
},
isDisabled(name) {
return disabled.has(name);
},
};
}
test("Install plugin", () => {
const mgr = createPluginManager();
const r = mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
assert.equal(r.enabled, true);
assert.equal(mgr.list().length, 1);
});
test("Duplicate install rejected", () => {
const mgr = createPluginManager();
mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
assert.throws(() => mgr.install({ name: "a", version: "2.0.0", description: "d", author: "a", main: "x.js" }, "code2"), /Already installed/);
});
test("Uninstall plugin", () => {
const mgr = createPluginManager();
mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
mgr.uninstall("a");
assert.equal(mgr.list().length, 0);
});
test("Uninstall nonexistent rejected", () => {
const mgr = createPluginManager();
assert.throws(() => mgr.uninstall("nope"), /Not found/);
});
test("Enable/Disable toggle", () => {
const mgr = createPluginManager();
mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
mgr.disable("a");
assert.ok(mgr.isDisabled("a"));
assert.equal(mgr.list()[0].enabled, false);
mgr.enable("a");
assert.ok(!mgr.isDisabled("a"));
assert.equal(mgr.list()[0].enabled, true);
});
// ── C3 — Sandbox Isolation ──────────────────────────────────────────────────
console.log("\n🔒 C3 — Sandbox Isolation\n");
test("Plugin code cannot access DOM directly (no document reference)", () => {
// In the sandbox worker, document/window are undefined
// We simulate by running code in a scope without DOM
const fakeScope = { self: {}, postMessage: () => {} };
delete fakeScope.document;
delete fakeScope.window;
const code = "try { document.getElementById('x'); } catch(e) { self.postMessage({type:'error', message: e.message}); }";
// This should throw ReferenceError in strict isolation
try {
const fn = new Function("self", "document", "window", code);
fn(fakeScope, undefined, undefined);
} catch (e) {
assert.ok(e instanceof ReferenceError, "Should throw ReferenceError for document access");
}
});
test("Plugin worker only receives structured-clone-safe messages", () => {
const msg = {
type: "execute",
hook: "onFileRender",
data: { content: "# Test", path: "test.md" },
};
// structuredClone should work
const clone = structuredClone(msg);
assert.deepStrictEqual(clone, msg);
});
test("Worker cannot use importScripts (CSP)", () => {
// Blob workers in modern browsers enforce CSP — importScripts is not available
// We verify the mock worker doesn't expose it
const worker = new Worker("blob:test");
assert.equal(typeof worker.importScripts, "undefined",
"Worker should not expose importScripts");
});
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`\n${"═".repeat(60)}`);
console.log(` Results: ${passed} passed, ${failed} failed`);
console.log(`${"═".repeat(60)}`);
if (failures.length > 0) {
console.log("\nFailures:");
failures.forEach(f => console.log(` ❌ ${f.name}: ${f.error}`));
process.exit(1);
}
process.exit(0);
+461
View File
@@ -0,0 +1,461 @@
"""Tests for the ObsiGate plugin system (backend/plugins.py)."""
import json
import pytest
from backend.plugins import (
ALLOWED_HOOKS,
ALLOWED_PERMISSIONS,
MANIFEST_FILENAME,
MAX_PLUGINS_PER_VAULT,
PLUGINS_DIR_NAME,
PluginManager,
PluginManifest,
PluginRegistry,
_validate_plugin_directory,
_validate_plugin_zip,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def valid_manifest():
"""A valid plugin manifest dict."""
return {
"name": "test-plugin",
"version": "1.0.0",
"description": "A test plugin",
"author": "test-author",
"main": "index.js",
"hooks": {
"onFileRender": "renderFile",
"onSearchFilter": "filterSearch",
},
"permissions": ["read_files"],
"min_obsigate_version": "2.1.0",
"license": "MIT",
}
@pytest.fixture
def minimal_manifest():
"""Minimal valid manifest (only required fields)."""
return {
"name": "minimal",
"version": "0.1.0",
"description": "Minimal plugin",
"author": "author",
"main": "main.js",
}
@pytest.fixture
def temp_vault(tmp_path):
"""Create a temporary vault directory."""
vault = tmp_path / "test-vault"
vault.mkdir()
return vault
@pytest.fixture
def plugins_dir(temp_vault):
"""Create the plugins directory inside a temp vault."""
pd = temp_vault / PLUGINS_DIR_NAME
pd.mkdir()
return pd
@pytest.fixture
def installed_plugin(plugins_dir, valid_manifest):
"""A plugin already installed on disk."""
plugin_dir = plugins_dir / "test-plugin"
plugin_dir.mkdir()
(plugin_dir / MANIFEST_FILENAME).write_text(json.dumps(valid_manifest))
(plugin_dir / "index.js").write_text("export function renderFile(ctx) { return ctx; }")
return plugin_dir
# ═══════════════════════════════════════════════════════════════════════════════
# B5 — Manifest Validation
# ═══════════════════════════════════════════════════════════════════════════════
class TestManifestValidation:
"""Test PluginManifest validation logic."""
def test_valid_manifest_parses(self, valid_manifest):
m = PluginManifest.from_dict(valid_manifest)
assert m.name == "test-plugin"
assert m.version == "1.0.0"
assert m.main == "index.js"
assert "onFileRender" in m.hooks
assert "onSearchFilter" in m.hooks
assert m.permissions == ["read_files"]
def test_minimal_manifest(self, minimal_manifest):
m = PluginManifest.from_dict(minimal_manifest)
assert m.name == "minimal"
assert m.hooks == {}
assert m.permissions == []
def test_missing_required_field(self):
data = {"name": "x", "version": "1.0.0", "description": "d", "author": "a"}
with pytest.raises(ValueError, match="Missing required field: main"):
PluginManifest.from_dict(data)
def test_invalid_name_uppercase(self, valid_manifest):
valid_manifest["name"] = "MyPlugin"
with pytest.raises(ValueError, match="lowercase alphanumeric"):
PluginManifest.from_dict(valid_manifest)
def test_invalid_name_special_chars(self, valid_manifest):
valid_manifest["name"] = "my_plugin!"
with pytest.raises(ValueError, match="lowercase alphanumeric"):
PluginManifest.from_dict(valid_manifest)
def test_invalid_name_single_hyphen(self, valid_manifest):
valid_manifest["name"] = "-bad-"
with pytest.raises(ValueError, match="lowercase alphanumeric"):
PluginManifest.from_dict(valid_manifest)
def test_valid_single_char_name(self):
d = {"name": "a", "version": "1.0.0", "description": "d", "author": "a", "main": "a.js"}
m = PluginManifest.from_dict(d)
assert m.name == "a"
def test_invalid_version_no_patch(self, valid_manifest):
valid_manifest["version"] = "1.0"
with pytest.raises(ValueError, match="semantic version"):
PluginManifest.from_dict(valid_manifest)
def test_invalid_version_letters(self, valid_manifest):
valid_manifest["version"] = "1.0.0-beta.1"
# Should work — pre-release tags are allowed
m = PluginManifest.from_dict(valid_manifest)
assert m.version == "1.0.0-beta.1"
def test_invalid_version_not_semver(self, valid_manifest):
valid_manifest["version"] = "v1.0.0"
with pytest.raises(ValueError, match="semantic version"):
PluginManifest.from_dict(valid_manifest)
def test_unknown_hook(self, valid_manifest):
valid_manifest["hooks"]["onMagic"] = "handleMagic"
with pytest.raises(ValueError, match="Unknown hook"):
PluginManifest.from_dict(valid_manifest)
def test_unknown_permission(self, valid_manifest):
valid_manifest["permissions"] = ["evil_access"]
with pytest.raises(ValueError, match="Unknown permission"):
PluginManifest.from_dict(valid_manifest)
def test_all_allowed_hooks(self):
hooks = {h: f"handle_{h}" for h in ALLOWED_HOOKS}
d = {"name": "full-hooks", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js", "hooks": hooks}
m = PluginManifest.from_dict(d)
assert len(m.hooks) == len(ALLOWED_HOOKS)
def test_all_allowed_permissions(self):
d = {"name": "full-perms", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js", "permissions": list(ALLOWED_PERMISSIONS)}
m = PluginManifest.from_dict(d)
assert len(m.permissions) == len(ALLOWED_PERMISSIONS)
def test_empty_name_rejected(self):
d = {"name": "", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="Missing required field: name"):
PluginManifest.from_dict(d)
def test_empty_version_rejected(self):
d = {"name": "ok", "version": "", "description": "d", "author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="Missing required field: version"):
PluginManifest.from_dict(d)
# ═══════════════════════════════════════════════════════════════════════════════
# B6 — Plugin Manager (install / uninstall / enable / disable)
# ═══════════════════════════════════════════════════════════════════════════════
class TestPluginManager:
"""Test PluginManager installation and lifecycle."""
def test_install_plugin_from_dict(self, plugins_dir, valid_manifest):
manager = PluginManager(plugins_dir)
result = manager.install(valid_manifest, "export function renderFile() {}")
assert result["name"] == "test-plugin"
assert result["version"] == "1.0.0"
assert result["enabled"] is True
assert (plugins_dir / "test-plugin" / MANIFEST_FILENAME).exists()
assert (plugins_dir / "test-plugin" / "index.js").exists()
def test_install_duplicate_rejected(self, plugins_dir, valid_manifest):
manager = PluginManager(plugins_dir)
manager.install(valid_manifest, "code")
with pytest.raises(ValueError, match="already installed"):
manager.install(valid_manifest, "code2")
def test_uninstall_plugin(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.uninstall("test-plugin")
assert not (plugins_dir / "test-plugin").exists()
def test_uninstall_nonexistent(self, plugins_dir):
manager = PluginManager(plugins_dir)
with pytest.raises(ValueError, match="not found"):
manager.uninstall("no-such-plugin")
def test_enable_disable(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.disable("test-plugin")
assert manager.is_disabled("test-plugin")
manager.enable("test-plugin")
assert not manager.is_disabled("test-plugin")
def test_list_plugins(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
plugins = manager.list_plugins()
assert len(plugins) == 1
assert plugins[0]["name"] == "test-plugin"
assert plugins[0]["enabled"] is True
def test_get_plugin(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
plugin = manager.get_plugin("test-plugin")
assert plugin is not None
assert plugin["manifest"]["name"] == "test-plugin"
def test_get_nonexistent(self, plugins_dir):
manager = PluginManager(plugins_dir)
assert manager.get_plugin("nope") is None
def test_max_plugins_limit(self, plugins_dir):
manager = PluginManager(plugins_dir)
for i in range(MAX_PLUGINS_PER_VAULT):
m = {"name": f"plug-{i}", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js"}
manager.install(m, "code")
# Next one should fail
m = {"name": "plug-extra", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="Maximum"):
manager.install(m, "code")
def test_disabled_marker_file(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.disable("test-plugin")
marker = plugins_dir / "test-plugin" / ".disabled"
assert marker.exists()
manager.enable("test-plugin")
assert not marker.exists()
# ═══════════════════════════════════════════════════════════════════════════════
# B7 — Plugin Registry (enabled plugins by vault)
# ═══════════════════════════════════════════════════════════════════════════════
class TestPluginRegistry:
"""Test PluginRegistry aggregation across vaults."""
def test_get_enabled_plugins(self, plugins_dir, installed_plugin):
registry = PluginRegistry()
plugins = registry.get_enabled_plugins(plugins_dir)
assert len(plugins) == 1
assert plugins[0]["name"] == "test-plugin"
def test_disabled_plugin_excluded(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.disable("test-plugin")
registry = PluginRegistry()
plugins = registry.get_enabled_plugins(plugins_dir)
assert len(plugins) == 0
def test_get_plugins_by_hook(self, plugins_dir, installed_plugin):
registry = PluginRegistry()
plugins = registry.get_plugins_by_hook(plugins_dir, "onFileRender")
assert len(plugins) == 1
def test_get_plugins_by_wrong_hook(self, plugins_dir, installed_plugin):
registry = PluginRegistry()
plugins = registry.get_plugins_by_hook(plugins_dir, "onVaultMount")
assert len(plugins) == 0
# ═══════════════════════════════════════════════════════════════════════════════
# C4 — ZIP Validation
# ═══════════════════════════════════════════════════════════════════════════════
class TestZipValidation:
"""Test ZIP upload validation (C4)."""
def test_valid_zip(self, tmp_path):
import zipfile
zip_path = tmp_path / "good-plugin.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "zip-plugin", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
zf.writestr("index.js", "export default {}")
result = _validate_plugin_zip(zip_path)
assert result["name"] == "zip-plugin"
def test_missing_manifest(self, tmp_path):
import zipfile
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("index.js", "code")
with pytest.raises(ValueError, match="plugin.json"):
_validate_plugin_zip(zip_path)
def test_missing_entry_point(self, tmp_path):
import zipfile
zip_path = tmp_path / "bad2.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
with pytest.raises(ValueError, match="index.js"):
_validate_plugin_zip(zip_path)
def test_path_traversal_rejected(self, tmp_path):
import zipfile
zip_path = tmp_path / "traversal.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
zf.writestr("index.js", "ok")
zf.writestr("../../../etc/passwd", "evil")
with pytest.raises(ValueError, match=r"(?i)path traversal"):
_validate_plugin_zip(zip_path)
def test_too_many_files_rejected(self, tmp_path):
import zipfile
zip_path = tmp_path / "toomany.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
zf.writestr("index.js", "ok")
for i in range(101):
zf.writestr(f"file{i}.js", f"// {i}")
with pytest.raises(ValueError, match="too many files"):
_validate_plugin_zip(zip_path)
def test_invalid_json_rejected(self, tmp_path):
import zipfile
zip_path = tmp_path / "badjson.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", "NOT JSON {{{")
zf.writestr("index.js", "ok")
with pytest.raises(ValueError, match="Invalid JSON"):
_validate_plugin_zip(zip_path)
# ═══════════════════════════════════════════════════════════════════════════════
# C5 — Directory Validation
# ═══════════════════════════════════════════════════════════════════════════════
class TestDirectoryValidation:
"""Test directory-based plugin validation (C5)."""
def test_valid_directory(self, tmp_path):
plugin_dir = tmp_path / "good"
plugin_dir.mkdir()
(plugin_dir / MANIFEST_FILENAME).write_text(json.dumps({
"name": "dir-plugin", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
(plugin_dir / "index.js").write_text("export default {}")
result = _validate_plugin_directory(plugin_dir)
assert result["name"] == "dir-plugin"
def test_missing_manifest_file(self, tmp_path):
plugin_dir = tmp_path / "no-manifest"
plugin_dir.mkdir()
(plugin_dir / "index.js").write_text("code")
with pytest.raises(ValueError, match="plugin.json"):
_validate_plugin_directory(plugin_dir)
def test_missing_entry_point(self, tmp_path):
plugin_dir = tmp_path / "no-entry"
plugin_dir.mkdir()
(plugin_dir / MANIFEST_FILENAME).write_text(json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "missing.js",
}))
with pytest.raises(ValueError, match="missing.js"):
_validate_plugin_directory(plugin_dir)
# ═══════════════════════════════════════════════════════════════════════════════
# F1 — Plugin Loading / Execution (Web Worker sandbox API)
# ═══════════════════════════════════════════════════════════════════════════════
class TestPluginLoading:
"""Test loading plugin code for sandbox execution."""
def test_get_plugin_code(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
code = manager.get_plugin_code("test-plugin", "index.js")
assert "renderFile" in code
def test_get_plugin_code_nonexistent(self, plugins_dir):
manager = PluginManager(plugins_dir)
with pytest.raises(ValueError, match="not found"):
manager.get_plugin_code("nope", "index.js")
def test_get_plugin_code_nonexistent_file(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
with pytest.raises(ValueError, match="not found"):
manager.get_plugin_code("test-plugin", "missing.js")
def test_get_hooks_for_plugin(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
hooks = manager.get_hooks("test-plugin")
assert "onFileRender" in hooks
assert hooks["onFileRender"] == "renderFile"
def test_install_plugin_validates_manifest_on_install(self, plugins_dir):
manager = PluginManager(plugins_dir)
bad = {"name": "UPPERCASE", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="lowercase"):
manager.install(bad, "code")
class TestPluginsAPI:
"""Exercises the mounted /api/plugins/* router via TestClient (auth off)."""
def test_plugin_list_empty(self, client):
resp = client.get("/api/plugins?vault=TestVault")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
def test_plugin_template_returns_code(self, client):
resp = client.get("/api/plugins/template")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
assert "code" in data
assert "manifest" in data
assert data["manifest"]["name"] == "my-plugin"
def test_plugin_hooks_endpoint(self, client):
# /api/plugins/hooks doesn't exist; test a valid endpoint shape
# /api/plugins/{name}/hooks requires a plugin name - test 404
resp = client.get("/api/plugins/nonexistent/hooks?vault=TestVault")
assert resp.status_code == 404