Skip to content

SmartTable Plugin Architecture

Related: Plugin Developer Guide

1. Overview

1.1 Goals

Provide SmartTable with a secure, extensible plugin system so third-party developers can build custom plugins against a clear manifest specification and API documentation, and deploy them into existing installations as packages.

1.2 Design References

ReferenceBorrowed ideas
Lark Base pluginsDeclarative manifest registration, iframe sandbox execution, host-provided SDK, permission grant at install time
SeaTable ScriptsBackend scripts for batch table operations, run history/logs, restricted API proxy

1.3 Two Plugin Types

TypeRuns inUICommunicationTypical scenarios
ui frontend pluginBrowser iframe sandboxYespostMessage JSON-RPCPanels, toolbar buttons, record blocks
script backend pluginControlled server-side subprocessNostdio protocol frames proxyBatch data processing, scheduled tasks (reserved)

Both types share the same manifest specification, lifecycle model, permission model and configuration storage; they diverge only in runtime mechanics.

1.4 Overall Architecture

Key invariants:

  1. Plugins never hold user credentials — all data operations are proxied and authorized by the host;
  2. Plugins are global resources — package files are shared globally; a Base only holds install/enable relations;
  3. Effective enablement = global status === enabled AND plugin_installations.enabled === true; the frontend registry only mounts effectively enabled plugins.

2. Manifest Specification (manifest.json)

2.1 Full Field Definition

json
{
  "id": "com.example.hello-panel",
  "name": "Hello Panel",
  "description": "Sample plugin",
  "icon": "icon.png",
  "author": { "name": "Example", "url": "https://example.com" },
  "version": "1.0.0",
  "type": "ui",
  "apiVersion": "1",
  "engines": { "smarttable": ">=1.7.0 <2.0.0" },
  "entry": "main.js",
  "permissions": {
    "records": "write",
    "tables": "read",
    "storage": true,
    "network": ["api.example.com"]
  },
  "extensionPoints": [
    { "type": "toolbar-button", "title": "Hello", "icon": "Star" },
    { "type": "side-panel", "title": "Hello Panel" }
  ],
  "configSchema": {
    "type": "object",
    "properties": { "greeting": { "type": "string", "default": "Hello" } }
  },
  "script": { "timeout": 60 }
}

2.2 Field Description

FieldRequiredTypeDescription
idstringReverse-domain com.<org>.<name>, globally unique, immutable after install
namestringDisplay name (2-50 chars)
descriptionstringDescription (≤500 chars)
iconstringRelative path of the icon inside the package (png/svg, ≤64KB)
authorobjectAuthor information
versionstringsemver MAJOR.MINOR.PATCH
typeenumui / script
apiVersionstringHost plugin API major version, currently "1"
enginesobjectHost version compatibility range (npm semver-range syntax)
entrystringRelative entry path: .js for ui, .py for script
permissionsobjectObject-based graded permission declaration (see 2.3)
extensionPointsrequired for uiarrayUI extension point declarations (see 2.4)
configSchemaJSON SchemaPlugin configuration structure (Draft-07 subset)
script.timeoutoptional for scriptnumberScript timeout seconds, default 30, max 300

2.3 Permission Declaration (Object-based, Graded)

permissions: {
  "records": "read" | "write",     // record read/write
  "tables":  "read" | "write",     // schema read/write (write includes create/delete table)
  "storage": true,                 // plugin-owned KV storage
  "config":  true,                 // read own configuration (implicitly granted)
  "network": ["api.example.com"]   // allowlist of third-party domains reachable via the host proxy (delivered: UI via network.fetch SDK, scripts via /proxy API)
}

Design decision: object-based graded permissions instead of a flat string array (e.g. read:records). Rationale: fine-grained permissions (table/field scoping) can be added to the value later (e.g. {"records": {"level": "write", "tables": ["tbl_xxx"]}}), whereas a flat form would be a breaking change. Undeclared permission points are denied by default.

config is implicitly granted: reading its own configuration is a basic capability and needs no explicit declaration.

2.4 UI Extension Point Types (First Release)

typeMounted atBehavior
toolbar-buttonTable view toolbarClick opens the side panel or triggers the plugin
side-panelRight drawerHosts the iframe sandbox rendering the plugin UI
base-menuBase top extension menuMenu item click opens the side panel
record-detail-blockBottom block of the record detail drawerHosts the iframe sandbox
home-menuHome page global extension menuGlobal-scoped entry, no Base install needed (menu item click opens the side panel)
dashboard-widgetDashboard custom widget areaHosts the iframe sandbox rendering the plugin widget

Extension points are registered declaratively; the host renders them from the manifest and plugins never (and cannot) manipulate host DOM directly.

toolbar-button and side-panel form an "entry ↔ content" pairing: side-panel has no entry independent of toolbar-button; declare the two together (see Developer Guide §2.2). The other extension points (base-menu / record-detail-block / home-menu / dashboard-widget) each have their own independent host entry and are not subject to this constraint.

Optional selection dependency declarations (apply to that extension point entry):

FieldTypeDescription
requiresSelectionbooleanWhen true, the host disables the entry with a hint if nothing is selected in the table; guarantees the plugin receives a non-empty selection
maxSelectionnumber(1-1000)Maximum selected records allowed; the host disables the entry with a hint when exceeded, preventing plugins from processing huge datasets

2.5 Dependency Management Scope

Inter-plugin dependencies are not supported in the first release (no dependencies field); only host version compatibility declarations (engines + apiVersion) are supported. Rationale: dependency graph resolution, cycle detection and install ordering are too heavy for the first release; host API version negotiation already covers core compatibility needs. Future path: add dependencies: {"com.example.lib": ">=1.0.0"} to the manifest with topological ordering at install time — no conflict with this architecture.


3. Lifecycle Management

3.1 State Machine

                 upload (zip validation passed)


                  [installed] ──enable──▶ [enabled]
                        ▲                    │
                        └───disable──────────┤
                                             │ N consecutive failures

                                          [error] ──re-enable──▶ [enabled]
                         any state ──uninstall──▶ (deleted)

Global status (plugins.status): installed (installed, not enabled) / enabled / disabled / error (set automatically after consecutive failures, recoverable manually).

3.2 Operations and Permission Subjects (Two-layer RBAC)

OperationAPISubjectNotes
Upload/install/upgrade/rollback/uninstallPOST /api/plugins/upload etc.System Admin (User.is_admin)Plugin packages are global resources
Global enable/disablePUT /api/plugins/<id>/statusSystem AdminGlobal kill switch
Base-level install/enable/disablePOST/PUT /api/plugins/<id>/installationsBase creator (Base.owner_id)Independent decision per Base, independent of the caller's member role in that Base; prerequisite: the plugin has been installed and globally enabled by a system admin (otherwise 403 plugin_not_enabled). Only meaningful for UI plugins: installations exist solely for Base distribution and mounting (registry isEffective + sandbox loader URL check). Script runs are governed by RBAC + the global switch and do not consume installations; the management page offers no Base install entry for them
Config read/writeGET/PUT /api/plugins/<id>/configBase creator (base scope) / System Admin (global scope)Validated by configSchema
Manual script runPOST /api/plugins/<id>/runBase Editor and above (on that Base)Proxied as the triggering user

When the global status is disabled/error, Base-level enabled has no effect — the registry only mounts plugins that are "globally enabled and Base-level enabled".

Management UI convention (implementation and product decision): the UI entry points are centralized on the plugin management page (/plugins, PluginManage.vue) — the page is open to every signed-in user and renders capabilities by identity:

  • System admins: upload packages, global enable/disable, rollback, uninstall, global config, script run and run logs;
  • Base creators: install/enable/remove in the card's "Base Installation" block (for the Base they created and selected in the dropdown) plus Base-level config.

Base editing pages provide no plugin install/management UI; they only consume "effectively enabled" plugins (registry mounting) and the run entry. The API-level "Base creator" check is the final guarantee — a non-creator is rejected even when bypassing the UI (the legacy /admin/plugins path redirects to /plugins).

3.3 Installation Flow

  1. Upload .stplugin.zip (multipart);
  2. Server-side extraction and validation:
    • zip path traversal protection (reject absolute paths, .., drive-letter prefixes);
    • zip bomb protection: total uncompressed size ≤ 50MB, file count ≤ 500, per-file compression ratio anomaly detection;
    • manifest.json present and validated by jsonschema;
    • entry file exists inside the package;
    • version conforms to semver;
    • engines.smarttable compatible with the host version (read from version.json);
    • apiVersion supported by the host;
    • extension point / permission declaration structures valid;
  3. Compute the package checksum (sha256);
  4. Store under uploads/plugins/<plugin_id>/<version>/;
  5. Write plugins + plugin_versions rows with status installed;
  6. Enablement and distribution (all via the plugin management page UI): global enable → pick the target Base in the "Base Installation" block and install+enable → the registry mounts the plugin on that Base page (UI extension points active / script runnable). Base/table parameters required by a UI plugin are configured by admins in the "Config" dialog under the Base scope; users simply open the plugin on the Base page.

3.4 Upgrade and Rollback

  • Upgrade: uploading a higher version of the same plugin_id → validation passes → new version directory recorded in plugin_versions, plugins.current_version pointer updated; all configs/installations preserved (if configSchema is incompatible with existing configuration, the upgrade is rejected with conflict details);
  • Rollback: POST /api/plugins/<id>/rollback with a retained version from plugin_versions, switching the plugins.current_version pointer; old version directories are always kept (cleaned up only on uninstall);
  • Downgrade protection: uploading a package with a lower version is rejected (rollback uses the dedicated API, not upload).

3.5 Uninstall Semantics

Uninstall = global deletion: remove plugins, all plugin_versions, all plugin_configs, all plugin_installations, plugin_run_logs (soft delete with 30-day retention is a future option; hard delete in the first release) plus the package directory. Disabling/upgrading does not delete data.


4. Permission Model

4.1 Model Composition

manifest declaration (request) ──▶ grantor confirmation at install ──▶ per-request runtime check ──▶ deny + audit on violation
  1. Declaration: the plugin statically declares required permissions in the manifest; runtime requests are not allowed (avoids "phishing-style" incremental escalation);
  2. Grant: system admins see the permission summary at upload; the Base creator sees it again at Base-level install/enablement — two independent decisions;
  3. Check: the host bridge (frontend RPC bridge / backend stdio proxy loop) compares each method call against the manifest permissions;
  4. Deny and audit: violations return PERMISSION_DENIED and are written to run/audit logs (with plugin_id, method, triggering user).

4.2 Permission Point to API Method Mapping

Permission pointFrontend RPC methodsBackend script APIs
records: readtable.getRecords, table.searchRecords, table.getRecordbase.list_records(), base.get_record()
records: writerecord.create, record.update, record.delete, record.batchUpdatebase.create_record(), base.update_record(), base.delete_record()
tables: readtable.getSchema, table.listTablesbase.list_tables(), base.get_fields()
tables: writetable.addField, table.updateFieldbase.add_field()… (reserved for the script side)
storagestorage.get/set/removebase.storage_get/set() (plugin KV independent of table data)
config (implicit)config.getbase.get_config()
UI capabilities (no declaration)ui.notify, ui.setPanelTitle

4.3 Data Operation Identity

  • UI plugins: the host forwards requests to existing REST APIs using the current user's JWT — permissions are double-checked (plugin permission point + user RBAC); data the user cannot access is also inaccessible to the plugin;
  • Script plugins: the host proxy executes as the triggering user (manual run = the triggering user). When scheduled execution arrives, a "plugin service identity" (degraded read-only/limited identity within the Base Owner's grant) will be added — reserved here.

4.4 Audit Attribution

Data changes performed by the proxy carry via_plugin: <plugin_id> metadata in change history/audit logs, distinguishing "human operations" from "plugin operations" so batch operations remain accountable.


5. Communication Interfaces

5.1 Frontend: Handshake + postMessage JSON-RPC

5.1.1 Sandbox Isolation

The iframe is loaded from a same-origin URL /api/plugins/<id>/versions/<version>/loader.html but with sandbox="allow-scripts" (without allow-same-origin). Effects:

  • iframe content gets an opaque origin (origin === "null"); even though the URL is same-origin, it cannot access host cookies, localStorage or DOM;
  • postMessage messages that have not passed the handshake are discarded.

Note: because the origin is always "null", event.origin allowlists cannot be used for source validation (also null when dev ports differ, e.g. 5173→5000).

5.1.2 Handshake Protocol

Host                                    iframe plugin
  │  create iframe, generate one-time token │
  │  URL: loader.html#token=<token>         │
  │  (fragment never reaches server logs/Referer)
  │ ────────────────────────────────────▶  │
  │                                        │ parse fragment → token
  │ ◀──────── init { token } ─────────────  │
  │  verify token, bind channel by          │
  │  (source window, token)                 │
  │ ───────── initAck { sdk version, perms }▶│
  │                                        │
  │ ◀══════ rpc.request { id, method, params } ════│
  │ ══════ rpc.response { id, result | error } ▶  │

The token is a one-time UUID; the host keeps a (iframeWindow → pending token) map and destroys the token after handshake to prevent replay.

5.1.3 RPC Message Format

json
// request
{ "type": "rpc.request", "id": "req-1", "method": "table.getRecords", "params": { "tableId": "tbl_x", "page": 1 } }
// success response
{ "type": "rpc.response", "id": "req-1", "result": { "items": [], "total": 0 } }
// error response
{ "type": "rpc.response", "id": "req-1", "error": { "code": "PERMISSION_DENIED", "message": "..." } }

Error codes: PERMISSION_DENIED / NOT_FOUND / VALIDATION_ERROR / RATE_LIMITED / MESSAGE_TOO_LARGE / INTERNAL_ERROR / API_VERSION_MISMATCH.

5.1.4 Protections

  • Rate limit: 50 req/s per plugin instance; exceeding returns RATE_LIMITED;
  • Message size: single postMessage ≤ 256KB;
  • Data requests proxied by the host: the host bridge calls api-surface (reusing frontend services/client.ts); plugins never issue HTTP requests directly (they have no token);
  • loader.html generated dynamically by a backend static route: injects window.SmartTableSDK (handshake, RPC client wrapper, sdk.ready(callback)); plugin JS is a zero-build IIFE.

5.1.5 Event Subscription (Reserved)

The protocol reserves rpc.subscribe(event) / rpc.unsubscribe(event) semantics (e.g. data.recordsChanged), forwarded through the existing WebSocket realtime pipeline. Not implemented in the first release; documented so third parties do not hack around it with polling.

5.2 Backend: Script Sandbox stdio Protocol Frames

5.2.1 Frame Format

Each subprocess stdout line is one JSON frame; protocol frames are separated from user output:

{"__rpc__": "call", "id": "c1", "method": "base.list_records", "params": {...}}   ← script→host (proxy call)
{"__rpc__": "result", "id": "c1", "result": {...}}                                ← host→script (via stdin)
{"__rpc__": "log", "message": "processed 100 records"}                            ← script log (print captured)
{"__rpc__": "done", "status": "success", "result": {...}}                         ← script finished

Non-protocol output (whatever the script writes to stdout) is captured by the runner and wrapped into log frames so the protocol channel stays clean.

5.2.2 Restricted Runtime

Built on and extending the existing app/script_runner/python_runner.py:

  • Restricted builtins: open/exec/eval/__import__/compile/globals/locals/vars/input/breakpoint/exit/quit removed;
  • Module allowlist: json/re/math/datetime/decimal/collections/itertools/hashlib/base64/uuid/statistics/time (time reading only);
  • Injected proxy object: base (restricted table API of the current Base) — each method call is sent to the host as a stdout frame; the host executes it in a Flask app context as the triggering user (reusing existing services + permission checks) and returns the result via stdin;
  • print capture: sys.stdout is redirected so print output becomes log frames.

5.2.3 Security Boundaries (Honest Statement)

Restricted builtins + import allowlist is "controlled execution", not a strong sandbox: Python offers escape surfaces at the language level (e.g. the ().__class__.__bases__ chain). Defense in depth:

  1. Subprocess isolation (crashes/timeouts do not affect the host);
  2. Timeout (default 30s, declarable via manifest, capped at 300s) kills the process;
  3. Output size limits (result ≤ 1MB, logs truncated, reusing existing script_execution_service constants);
  4. Concurrency cap on subprocesses (≤ CPU cores; queue or reject beyond that);
  5. Production recommendation: run backend subprocesses as a low-privilege OS user (documented in the ops guide);
  6. Scripts have no network capability (no network libraries in the allowlist).

5.2.4 Error Isolation

  • A single failed run only records a plugin_run_logs entry (traceback stored truncated, following the existing log-masking rules);
  • N consecutive failures (default 5) automatically set the global error status; the frontend offers disable/retry;
  • The host-side proxy loop runs on a dedicated thread; exceptions do not affect HTTP request handling.

5.3 REST Management APIs (/api/plugins)

MethodPathPermissionNotes
POST/api/plugins/uploadSystem AdminUpload/install/upgrade zip
GET/api/pluginsLogged-in userPlugin list (with Base-level status)
GET/api/plugins/<id>Logged-in userDetail (with version history)
PUT/api/plugins/<id>/statusSystem AdminGlobal enable/disable/restore
POST/api/plugins/<id>/rollbackSystem AdminRoll back to a specific version
DELETE/api/plugins/<id>System AdminUninstall
GET/api/plugins/<id>/versionsLogged-in userVersion list
GET/PUT/api/plugins/<id>/configSee 3.2Config read/write (scope parameter)
GET/api/plugins/<id>/installationsBase memberBase-level installation status
POST/PUT/DELETE/api/plugins/<id>/installationsBase creatorBase-level install/enable/remove (UI plugins only: POST/PUT return 400 PLUGIN_TYPE_NOT_INSTALLABLE for script plugins; DELETE remains for cleaning up existing relations). POST/PUT additionally require the plugin to be globally enabled
POST/api/plugins/<id>/runBase Editor+Manual script run
GET/api/plugins/<id>/run-logsBase Owner/AdminRun logs
GET/api/plugins/<id>/versions/<v>/loader.htmlLogged-in user (session)UI plugin sandbox loader
GET/api/plugins/<id>/versions/<v>/files/<path>Logged-in user (session)Plugin static assets (send_from_directory, traversal-safe)

REST paths and static file paths are explicitly distinguished by the versions/<v>/files/ prefix to avoid Flask route ambiguity.

5.4 Sandbox Render Runtime (vendor)

Plugin UIs may be written with a standard frontend framework. The host injects a same-origin hosted render runtime in the loader so plugins need not inline a framework or depend on the internet:

RuntimeFileInjectionPurpose
Vue 3vue.global.prod.js (with template compiler)loader loads vendor/vue.global.prod.js before the plugin entry scriptplugins can use Vue.createApp({ template })

Key points:

  1. Same-origin + offline capable: vendor assets are hosted statically by the backend (app/plugins_sandbox/vendor/), no CDN, no permissions.network needed — works on intranets/offline;
  2. Security boundary: filename allowlist (only vue.global.js / vue.global.prod.js) plus send_from_directory traversal protection; vendor assets are read-only static resources, not part of the handshake authentication;
  3. Zero build: the plugin remains a single entry file with the template written as a string — no bundler required;
  4. CSP: the production CSP script-src already includes 'unsafe-eval' as required by the Vue template compiler (runtime compilation);
  5. Isolation unchanged: the injected Vue lives only inside the opaque-origin iframe, fully isolated from the host page's Vue instance — no shared DOM/state;
  6. Extensible: to add React or other runtimes later, follow the same "allowlisted vendor + loader injection" approach — the plugin contract stays the same.

5.5 Table Selection Channel (selection)

Goal: after selecting records in the table, clicking a plugin button hands the selected records to the plugin for processing.

Data channel (host → plugin)

StageImplementationLocation
Selection sourceA table implements SelectionProvider { getSelection(): SelectionSummary } and registers it; VTable merges "row selection + checkbox selection" with dedup, header select-all maps to all rows of the pageVTableView.getSelection() → registered in Base.vue
Entry availabilityThe page reports the selection summary (IDs + count only) on the records-select event; the registry keeps selection; the toolbar computes button disabled state and hints from requiresSelection / maxSelectionregistry.setSelection / PluginToolbar
Snapshot creationA snapshot is created once when the plugin is opened (RPC bridge creation) and written into the bridge context; it is not pushed on selection changesPluginSandboxbuildSelectionSnapshot()
Plugin readui.getContext() returns selection, or selection.get() aloneapi-surface

Snapshot structure: { recordIds: string[], total: number, truncated: boolean, selectAll: boolean, scope: "page" \| "view", at: number }

Constraints and integrity

  1. IDs only: record content is fetched on demand via table.getRecord, keeping bulk data out of the sandbox context and the postMessage channel;
  2. Cap of 1000: beyond that it is truncated with truncated set (total keeps the real value); both host and plugin hint the user to narrow the selection;
  3. Snapshot on open: no change push; selection changes require reopening the plugin (predictable, no event storms). If realtime behavior is needed later, a selection.change event can be added without breaking the contract;
  4. Stale cleanup: invalid IDs are removed after refresh/delete; the selection is cleared when switching tables to avoid cross-table leakage;
  5. Permissions: selection exposes IDs only; real reads/writes still go through the existing two-layer check (manifest permission point + user RBAC), so selection data is not a privilege escalation path.

Extensibility and compatibility

  • Host-side table adaptation is decoupled through the SelectionProvider interface: VTable is integrated; the native table or other component libraries only need to implement and register the same interface;
  • Plugins depend only on the global SmartTableSDK (postMessage + Promise) and are not tied to any frontend framework — Vue / React / vanilla JS work identically;
  • Extension point declarations (requiresSelection / maxSelection) are validated by the backend manifest schema; invalid declarations are rejected at install time.

6. Plugin Configuration Storage

6.1 Two-level Scopes

ScopeStorageWrite permissionPurpose
globalplugin_configs(scope=global)System AdminGlobal defaults
baseplugin_configs(scope=base, base_id=...)Base creatorBase overrides (UI: the "Config" dialog base tab on the plugin management page, maintained per selected Base)

Read rule: Base-level configuration is deep-merged over the global level (Base keys override same-named global keys).

6.2 Validation and Migration

  • Writes are validated against the manifest configSchema (JSON Schema Draft-07 subset);
  • Preserved on upgrade: configs are not deleted across versions; if a new version's configSchema is incompatible with existing configuration (validation fails), the upgrade is rejected with conflict details.

6.3 Plugin-owned KV Storage

The storage permission grants the plugin an independent KV store (a scope extension of plugin_configs or a dedicated table; in the first release under the plugin_storage key space, key length ≤ 128, single value ≤ 64KB, ≤ 1MB per plugin), isolated from host data.


7. Data Model (Alembic Migration)

plugins              global plugin records
├── plugin_versions  version history (upgrade/rollback support)
├── plugin_configs   two-level config + plugin KV
├── plugin_installations  Base-level install/enable relations
└── plugin_run_logs  script run records
TableKey fields
pluginsplugin_id(PK, string), name, description, icon, type, status, current_version, manifest(JSON), engines_text, created_at, updated_at
plugin_versionsid(PK), plugin_id(FK), version, package_path, checksum, installed_at
plugin_configsid(PK), plugin_id(FK), scope(global/base/kv), base_id(nullable FK), config_key, config(JSON), updated_by, updated_at
plugin_installationsid(PK), plugin_id(FK), base_id(FK), enabled, installed_by, installed_at; UNIQUE(plugin_id, base_id)
plugin_run_logsid(PK), plugin_id(FK), base_id(FK), status, duration_ms, triggered_by, output(truncated), error_summary, traceback(truncated), created_at

8. Error Isolation and Stability

LayerMechanism
Frontend pluginiframe load failure / heartbeat timeout (30s no response) only destroys that plugin's mount point and notifies the user; the host page is unaffected
Frontend RPCRate limiting + message size limits + unknown message discard
Backend scriptSubprocess timeout kill; a single failure only creates a RunLog; N consecutive failures set error
Backend hostProxy loop on a dedicated thread + Flask app context; proxy exceptions return INTERNAL_ERROR to the script
ResourcesSubprocess concurrency cap; extraction size/file count caps; RunLog/output truncation; log masking
Host API evolutionapiVersion negotiation + engines range check; incompatible plugins are rejected at install

9. Version Management

  1. Plugin version: semver; plugin_versions retains full history for rollback;
  2. Host version compatibility: the engines.smarttable range (host version read from version.json), checked at install/upgrade; if existing plugins become incompatible after a host upgrade they are set to disabled with a notice (reserved: batch validation task at startup);
  3. SDK/API version: apiVersion major-version negotiation; the host supports multiple versions side by side (starting with v1); breaking changes bump the major;
  4. Downgrade protection: uploading a lower version is rejected; rollback uses the dedicated API.

10. Distribution and Marketplace (Reserved)

The first release only supports uploading .stplugin.zip packages. The marketplace protocol is reserved:

json
// marketplace.json (market index, versioned static file on a CDN)
{
  "schemaVersion": 1,
  "updatedAt": "2026-09-07T00:00:00Z",
  "plugins": [
    {
      "id": "com.example.hello-panel",
      "name": "Hello Panel",
      "latestVersion": "1.2.0",
      "versions": {
        "1.2.0": { "url": "https://market.example.com/pkgs/hello-panel-1.2.0.stplugin.zip",
                    "sha256": "...", "publishedAt": "..." }
      },
      "publisher": { "id": "example", "verified": true },
      "signature": "<publisher signature over the package sha256; the host verifies it with a built-in public key>"
    }
  ]
}

When the marketplace is added: the host adds a "Browse Marketplace" page → fetch the index → download the package → reuse the same upload validation pipeline (zip protection / manifest validation / engines check) + signature verification → install. The install pipeline is fully reused; the marketplace is merely a new "package source".


11. Implementation Phases

PhaseScope
P1 (this release)Manifest specification, lifecycle APIs, two-layer RBAC, frontend iframe sandbox + handshake RPC, backend script sandbox, two-level config, management page skeleton, two sample plugins, developer guide; full extension points delivered (incl. home menu home-menu, dashboard widget dashboard-widget), in-package static assets (assets) and custom backend endpoints (endpoints), third-party network proxy (UI via network.fetch SDK, scripts via /proxy API, with SSRF protection)
P2Event subscription (rpc.subscribe), scheduled script triggers and plugin service identity
P3Marketplace (index protocol implementation + signature verification + marketplace page), inter-plugin dependencies

12. Security Design Checklist

  • [x] iframe opaque origin isolation (sandbox=allow-scripts, no allow-same-origin)
  • [x] RPC handshake token (one-time, passed via URL fragment, replay-proof)
  • [x] Per-request permission checks + deny by default
  • [x] Plugins never hold user credentials
  • [x] zip path traversal + zip bomb protection
  • [x] Static serving with send_from_directory traversal protection
  • [x] RPC rate / message size limits
  • [x] Script timeout / output / concurrency limits
  • [x] Audit attribution (via_plugin metadata)
  • [x] Log masking (reusing existing rules)
  • [ ] Production recommendation: run subprocesses as a low-privilege OS user (ops guide)
  • [ ] Not covered in the first release: plugin package signing (introduced with the marketplace)

Released under the MIT License.