Self-hosted knowledge keeper for 3D printing projects
3DKenji now loads plugins from an installation-specific external plugin root instead of from src/backend/plugins. This guide explains the v1 package layout and how to build plugins that can be discovered safely at startup.
The plugin system allows you to extend 3DKenji with custom implementations for:
Public API routes are not plugin-defined in v1. Core code owns request routing, permission checks, and response shaping.
Plugins are discovered from PLUGINS_ROOT at application startup.
Default locations:
/data/plugins/working/pluginsEach plugin package is one directory under PLUGINS_ROOT:
PLUGINS_ROOT/
├── _system/
│ └── plugin-registry.yaml
└── core-themes/
├── plugin.yaml
├── settings.yaml
└── assets/
└── themes/
├── dark.css
└── light.css
The runtime PluginManager discovers plugin folders from PLUGINS_ROOT, validates plugin.yaml, ensures settings.yaml exists, merges central enable-state from _system/plugin-registry.yaml, and then exposes active theme and viewer contributions to the application.
from backend.core.plugins import PluginManager
manager = PluginManager()
await manager.load_plugins(app, {})
themes = manager.get_by_type("theme")
plugins = manager.list_plugins()
Each plugin package must contain a plugin.yaml manifest. Example:
id: core-themes
name: Core Themes
version: 0.1.0
author: 3DKenji
type: cosmetic
description: Bundled dark and light themes.
themes:
- name: dark
label: Dark
css_file: assets/themes/dark.css
default: true
- name: light
label: Light
css_file: assets/themes/light.css
settings_defaults:
default_theme: dark
Each plugin owns an editable settings.yaml. The admin plugin page serves this file in a text editor and writes it back after YAML validation.
Internal implementation note: markdown files opened through the standalone project file editor (/projects/{project_id}/files/editor) are rendered and edited with Toast UI Editor (tui.editor) loaded from the Toast CDN. The editor follows the active app theme (dark/light) and re-initializes on theme toggle so markdown source and preview remain readable in both modes.
Example:
default_theme: dark
Plugin enable/disable state is installation-level and stored outside the plugin package in:
plugins:
core-themes:
enabled: true
This data lives in PLUGINS_ROOT/_system/plugin-registry.yaml.
Cosmetic plugins contribute CSS assets only in v1. A single plugin package can expose multiple named theme variants, which is how the bundled dark and light themes are implemented now.
Viewer plugins are matched by file extension declared in plugin.yaml.
Example manifest fragment:
viewers:
- id: stl-viewer
name: STL Viewer
extensions:
- stl
js_file: assets/viewers/stl-viewer.js
backend_entrypoint: viewer.renderers.stl:render
Viewer plugins may ship JS assets and backend entrypoint metadata, but core routes still decide when they execute.
settings.yaml and the central enable-state registry only.settings.yaml is missing, the runtime creates it from settings_defaults declared in plugin.yaml.
async def health_check(self) -> ProcessResult:
“"”Check S3 connectivity”””
try:
self.s3_client.head_bucket(Bucket=self.bucket_name)
return ProcessResult(
success=True,
message=”S3 bucket accessible”
)
except Exception as e:
return ProcessResult(
success=False,
message=f”S3 error: {str(e)}”
)
```Update backend/main.py:
from backend.plugins.storage_s3 import S3StorageBackend
import os
# Initialize S3 storage
storage_backend = S3StorageBackend(
bucket_name=os.getenv("S3_BUCKET_NAME"),
region_name=os.getenv("S3_REGION", "us-east-1"),
access_key=os.getenv("AWS_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
)
Always handle errors gracefully:
async def authenticate(self, provider_data: dict) -> Optional[AuthResult]:
try:
# ... authentication logic
except httpx.RequestError as e:
logger.error(f"Auth provider error: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error: {e}")
return None
All plugin methods are async. Use await for I/O:
async def authenticate(self, provider_data: dict) -> Optional[AuthResult]:
# ✓ Correct: async I/O
response = await self.client.get(url)
# ✗ Wrong: blocking I/O
response = requests.get(url)
Use environment variables for plugin configuration:
def __init__(self):
self.api_key = os.getenv("PLUGIN_API_KEY")
self.timeout = int(os.getenv("PLUGIN_TIMEOUT", "30"))
Log important events:
from backend.logging_config import get_logger
logger = get_logger(__name__)
async def authenticate(self, provider_data: dict):
logger.info(f"Authenticating with {self.name}")
# ... authentication logic
logger.debug(f"Auth result: {result}")
Create tests for your plugin:
# tests/unit/test_auth_github.py
import pytest
from backend.plugins.auth_github import GitHubAuthProvider
@pytest.fixture
def github_provider():
return GitHubAuthProvider(
client_id="test-client-id",
client_secret="test-client-secret",
)
@pytest.mark.asyncio
async def test_authenticate(github_provider):
result = await github_provider.authenticate({"code": "test-code"})
assert result is None or result.success is False # Without real credentials
class AuthProvider:
async def authenticate(provider_data: dict) -> Optional[AuthResult]:
"""Authenticate user with provider data"""
async def verify_token(token: str) -> Optional[UserIdentity]:
"""Verify a token is still valid"""
async def refresh_token(refresh_token: str) -> Optional[str]:
"""Get a new access token from refresh token"""
async def revoke_token(token: str) -> bool:
"""Revoke a token"""
class StorageBackend:
async def store(source_path: Path, destination_key: str) -> str:
"""Store a file"""
async def retrieve(storage_key: str) -> bytes:
"""Retrieve file contents"""
async def delete(storage_key: str) -> bool:
"""Delete a file"""
async def get_url(storage_key: str) -> str:
"""Get URL to access file"""
async def health_check() -> ProcessResult:
"""Check backend health"""
See auth_github.py example above.
See storage_s3.py example above.
To contribute a plugin to the main project:
/docs/plugins/