Plugin System
What exists today
The marketplace side of the plugin lifecycle is fully built: a developer creates a plugin listing, adds a version with a manifest and an uploaded package (.pluginpackage or .zip — the same ZIP container format either way), the package is scanned for malware, submits it, a moderator approves the version (which publishes it), and it becomes visible and downloadable in the store. See docs/plugin-manifest.md for the manifest format. Every uploaded package is malware-scanned before it can be submitted for review, and again independently at approval time.
What doesn't exist yet, on purpose
The desktop host application and the plugin runtime. Per the project brief, this repository is the website, API, database, and admin panel only. Nothing in this codebase parses, extracts, or executes a plugin package. This section documents how the runtime is expected to work so the desktop app can be built against a stable contract, and so every decision made in the backend (the manifest shape, the permission catalog, version/build numbers, checksums) is already compatible with it.
The intended architecture
klenua Desktop Host (native app)
├─ Marketplace UI — browses/installs via the same REST API as the website
├─ Plugin Runtime — loads an installed plugin's `entry` script in an
│ isolated context and exposes only the SDK below
└─ Installed plugins/ — one directory per plugin, per the manifest's `id`
A plugin never gets raw OS access. It gets whatever the host's SDK exposes, gated by the permissions it declared and the user accepted.
The future SDK surface
Namespaced, capability-gated modules a plugin's script would call into — none of this exists yet, but each maps directly onto a permission from the catalog in docs/plugin-manifest.md:
| Namespace | Purpose | Gated by |
|---|---|---|
app.ui | Render panels, dialogs, commands into the host's UI | — (always available) |
app.storage | Small persistent key/value storage, scoped per-plugin | localStorage |
app.files | Read/write files the user explicitly picks via a host-native picker | filesystem.read / filesystem.write |
app.clipboard | Read/write the system clipboard | clipboard.read / clipboard.write |
app.network | Outbound HTTP requests | network |
app.notifications | System notifications | notifications |
app.commands | Register commands into the host's command palette | — |
app.hooks | Subscribe to host/plugin events and transform values passing through them — proposed, see below | — |
app.settings | Read/write a plugin's own declared config — proposed, see below | — |
Design intent: a plugin script never touches fs/net/child_process directly (however the runtime is implemented — a JS engine, a WASM sandbox, or an OS-level sandboxed process are all viable and out of scope for this decision). It only ever sees the app.* object the host injects, so an ungranted permission is not just a policy the host promises to enforce — the capability is architecturally absent from what the plugin can even call.
Proposed: hooks, filters, settings, and dependencies
Everything above is the architecture already implied by the manifest and permission model built today. This section is different: a concrete proposal, not yet decided or built, for making plugins react to the host and to each other — closer to how WordPress plugins compose into an ecosystem — instead of every plugin being a fully isolated command.
Actions. A plugin subscribes to something happening in the host or in another plugin, instead of only running when a user triggers a command:
app.hooks.on('file.saved', (file) => {
// ...
});
Filters. A plugin can transform a value as it passes through the host or through another cooperating plugin, letting plugins chain off each other's output instead of only running in isolation:
app.hooks.filter('clipboard.beforeWrite', (value) => value.trim());
Both would live under a new app.hooks module. A plugin could only hook into another plugin's filters if that plugin explicitly registers them as public — one plugin extending another is opt-in on the side being extended, not something any installed plugin can reach into uninvited.
Settings API. A plugin declares its configurable options once, in its manifest's proposed settings field (see docs/plugin-manifest.md), and the host generates a settings screen for it automatically — a plugin author no longer hand-builds UI just to store a few options. Declared settings would be readable and writable at runtime via app.settings.get(key) / app.settings.set(key, value).
Dependencies. A plugin could declare other plugin ids it needs via the manifest's proposed requires field (see docs/plugin-manifest.md). The host would refuse to activate a plugin whose dependencies aren't installed, and the install flow would offer to install them alongside it — the same way a permission change is expected to be surfaced today, not silently allowed or silently blocked.
Lifecycle hooks. Beyond activate(app) (the only entry point today), a plugin's entry file would optionally export onInstall, onActivate, onDeactivate, and onUninstall, so it can set up or tear down its own local state at the right moment instead of doing it lazily on first use.
Why this isn't part of the contract yet. A hook/filter bus needs real conflict handling before it's safe to build a runtime around — priority and ordering when multiple plugins hook the same event, and error isolation so one plugin's broken filter can't corrupt the value the next plugin (or the host) receives. That's meaningfully more runtime complexity than today's model, where a plugin's failure is contained to its own command. app.hooks (actions only, no filters yet) and app.settings are the lower-risk half of this and would land first if this direction is adopted; filters and declared dependencies are a reasonable v2 once the runtime itself has been validated with real plugins.
Install / update flow (desktop, future)
- User clicks Get on the website or in the host app →
POST /api/v1/library/{pluginId} adds it to their library.
- Host app calls
GET /api/v1/plugins/{id}/latest-version, then
POST /api/v1/plugins/{id}/download for a short-lived signed token, and downloads via GET /api/v1/downloads/{token}.
- Host verifies the SHA-256 checksum returned alongside the package
before touching it.
- Host extracts into an isolated per-plugin directory (never anywhere
executable-by-default) and reports the install: POST /api/v1/installations.
- Update checks:
GET /api/v1/plugins/{id}/updates?current_version=X.
If the new version's permission set differs from what the user already accepted, the host is expected to show a permission-change prompt before installing — the version-scoped permission data (plugin_version_permissions) already supports diffing old vs. new.
Deep links
Plugin detail pages render a klenua://plugin/{slug} link today (inert until a host app registers the klenua:// scheme — APP_SCHEME in .env). The intent: the desktop host registers as the handler, and the link either opens the plugin if installed or opens its store page inside the host to offer installing it.