English
English
Appearance
English
English
Appearance
Related: Plugin Developer Guide
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.
| Reference | Borrowed ideas |
|---|---|
| Lark Base plugins | Declarative manifest registration, iframe sandbox execution, host-provided SDK, permission grant at install time |
| SeaTable Scripts | Backend scripts for batch table operations, run history/logs, restricted API proxy |
| Type | Runs in | UI | Communication | Typical scenarios |
|---|---|---|---|---|
ui frontend plugin | Browser iframe sandbox | Yes | postMessage JSON-RPC | Panels, toolbar buttons, record blocks |
script backend plugin | Controlled server-side subprocess | No | stdio protocol frames proxy | Batch 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.
Key invariants:
status === enabled AND plugin_installations.enabled === true; the frontend registry only mounts effectively enabled plugins.{
"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 }
}| Field | Required | Type | Description |
|---|---|---|---|
id | ✅ | string | Reverse-domain com.<org>.<name>, globally unique, immutable after install |
name | ✅ | string | Display name (2-50 chars) |
description | ❌ | string | Description (≤500 chars) |
icon | ❌ | string | Relative path of the icon inside the package (png/svg, ≤64KB) |
author | ❌ | object | Author information |
version | ✅ | string | semver MAJOR.MINOR.PATCH |
type | ✅ | enum | ui / script |
apiVersion | ✅ | string | Host plugin API major version, currently "1" |
engines | ✅ | object | Host version compatibility range (npm semver-range syntax) |
entry | ✅ | string | Relative entry path: .js for ui, .py for script |
permissions | ✅ | object | Object-based graded permission declaration (see 2.3) |
extensionPoints | required for ui | array | UI extension point declarations (see 2.4) |
configSchema | ❌ | JSON Schema | Plugin configuration structure (Draft-07 subset) |
script.timeout | optional for script | number | Script timeout seconds, default 30, max 300 |
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.
| type | Mounted at | Behavior |
|---|---|---|
toolbar-button | Table view toolbar | Click opens the side panel or triggers the plugin |
side-panel | Right drawer | Hosts the iframe sandbox rendering the plugin UI |
base-menu | Base top extension menu | Menu item click opens the side panel |
record-detail-block | Bottom block of the record detail drawer | Hosts the iframe sandbox |
home-menu | Home page global extension menu | Global-scoped entry, no Base install needed (menu item click opens the side panel) |
dashboard-widget | Dashboard custom widget area | Hosts 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-buttonandside-panelform an "entry ↔ content" pairing:side-panelhas no entry independent oftoolbar-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):
| Field | Type | Description |
|---|---|---|
requiresSelection | boolean | When true, the host disables the entry with a hint if nothing is selected in the table; guarantees the plugin receives a non-empty selection |
maxSelection | number(1-1000) | Maximum selected records allowed; the host disables the entry with a hint when exceeded, preventing plugins from processing huge datasets |
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.
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).
| Operation | API | Subject | Notes |
|---|---|---|---|
| Upload/install/upgrade/rollback/uninstall | POST /api/plugins/upload etc. | System Admin (User.is_admin) | Plugin packages are global resources |
| Global enable/disable | PUT /api/plugins/<id>/status | System Admin | Global kill switch |
| Base-level install/enable/disable | POST/PUT /api/plugins/<id>/installations | Base 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/write | GET/PUT /api/plugins/<id>/config | Base creator (base scope) / System Admin (global scope) | Validated by configSchema |
| Manual script run | POST /api/plugins/<id>/run | Base 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:
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).
.stplugin.zip (multipart);.., drive-letter prefixes);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;uploads/plugins/<plugin_id>/<version>/;plugins + plugin_versions rows with status installed;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);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);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.
manifest declaration (request) ──▶ grantor confirmation at install ──▶ per-request runtime check ──▶ deny + audit on violationPERMISSION_DENIED and are written to run/audit logs (with plugin_id, method, triggering user).| Permission point | Frontend RPC methods | Backend script APIs |
|---|---|---|
records: read | table.getRecords, table.searchRecords, table.getRecord | base.list_records(), base.get_record() |
records: write | record.create, record.update, record.delete, record.batchUpdate | base.create_record(), base.update_record(), base.delete_record() |
tables: read | table.getSchema, table.listTables | base.list_tables(), base.get_fields() |
tables: write | table.addField, table.updateField… | base.add_field()… (reserved for the script side) |
storage | storage.get/set/remove | base.storage_get/set() (plugin KV independent of table data) |
config (implicit) | config.get | base.get_config() |
| UI capabilities (no declaration) | ui.notify, ui.setPanelTitle | — |
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.
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:
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).
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.
// 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.
RATE_LIMITED;api-surface (reusing frontend services/client.ts); plugins never issue HTTP requests directly (they have no token);window.SmartTableSDK (handshake, RPC client wrapper, sdk.ready(callback)); plugin JS is a zero-build IIFE.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.
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 finishedNon-protocol output (whatever the script writes to stdout) is captured by the runner and wrapped into log frames so the protocol channel stays clean.
Built on and extending the existing app/script_runner/python_runner.py:
open/exec/eval/__import__/compile/globals/locals/vars/input/breakpoint/exit/quit removed;json/re/math/datetime/decimal/collections/itertools/hashlib/base64/uuid/statistics/time (time reading only);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;sys.stdout is redirected so print output becomes log frames.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:
script_execution_service constants);plugin_run_logs entry (traceback stored truncated, following the existing log-masking rules);error status; the frontend offers disable/retry;| Method | Path | Permission | Notes |
|---|---|---|---|
| POST | /api/plugins/upload | System Admin | Upload/install/upgrade zip |
| GET | /api/plugins | Logged-in user | Plugin list (with Base-level status) |
| GET | /api/plugins/<id> | Logged-in user | Detail (with version history) |
| PUT | /api/plugins/<id>/status | System Admin | Global enable/disable/restore |
| POST | /api/plugins/<id>/rollback | System Admin | Roll back to a specific version |
| DELETE | /api/plugins/<id> | System Admin | Uninstall |
| GET | /api/plugins/<id>/versions | Logged-in user | Version list |
| GET/PUT | /api/plugins/<id>/config | See 3.2 | Config read/write (scope parameter) |
| GET | /api/plugins/<id>/installations | Base member | Base-level installation status |
| POST/PUT/DELETE | /api/plugins/<id>/installations | Base creator | Base-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>/run | Base Editor+ | Manual script run |
| GET | /api/plugins/<id>/run-logs | Base Owner/Admin | Run logs |
| GET | /api/plugins/<id>/versions/<v>/loader.html | Logged-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.
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:
| Runtime | File | Injection | Purpose |
|---|---|---|---|
| Vue 3 | vue.global.prod.js (with template compiler) | loader loads vendor/vue.global.prod.js before the plugin entry script | plugins can use Vue.createApp({ template }) |
Key points:
app/plugins_sandbox/vendor/), no CDN, no permissions.network needed — works on intranets/offline;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;script-src already includes 'unsafe-eval' as required by the Vue template compiler (runtime compilation);Goal: after selecting records in the table, clicking a plugin button hands the selected records to the plugin for processing.
Data channel (host → plugin)
| Stage | Implementation | Location |
|---|---|---|
| Selection source | A 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 page | VTableView.getSelection() → registered in Base.vue |
| Entry availability | The 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 / maxSelection | registry.setSelection / PluginToolbar |
| Snapshot creation | A snapshot is created once when the plugin is opened (RPC bridge creation) and written into the bridge context; it is not pushed on selection changes | PluginSandbox → buildSelectionSnapshot() |
| Plugin read | ui.getContext() returns selection, or selection.get() alone | api-surface |
Snapshot structure: { recordIds: string[], total: number, truncated: boolean, selectAll: boolean, scope: "page" \| "view", at: number }
Constraints and integrity
table.getRecord, keeping bulk data out of the sandbox context and the postMessage channel;truncated set (total keeps the real value); both host and plugin hint the user to narrow the selection;selection.change event can be added without breaking the contract;Extensibility and compatibility
SelectionProvider interface: VTable is integrated; the native table or other component libraries only need to implement and register the same interface;SmartTableSDK (postMessage + Promise) and are not tied to any frontend framework — Vue / React / vanilla JS work identically;requiresSelection / maxSelection) are validated by the backend manifest schema; invalid declarations are rejected at install time.| Scope | Storage | Write permission | Purpose |
|---|---|---|---|
global | plugin_configs(scope=global) | System Admin | Global defaults |
base | plugin_configs(scope=base, base_id=...) | Base creator | Base 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).
configSchema (JSON Schema Draft-07 subset);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.
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| Table | Key fields |
|---|---|
plugins | plugin_id(PK, string), name, description, icon, type, status, current_version, manifest(JSON), engines_text, created_at, updated_at |
plugin_versions | id(PK), plugin_id(FK), version, package_path, checksum, installed_at |
plugin_configs | id(PK), plugin_id(FK), scope(global/base/kv), base_id(nullable FK), config_key, config(JSON), updated_by, updated_at |
plugin_installations | id(PK), plugin_id(FK), base_id(FK), enabled, installed_by, installed_at; UNIQUE(plugin_id, base_id) |
plugin_run_logs | id(PK), plugin_id(FK), base_id(FK), status, duration_ms, triggered_by, output(truncated), error_summary, traceback(truncated), created_at |
| Layer | Mechanism |
|---|---|
| Frontend plugin | iframe load failure / heartbeat timeout (30s no response) only destroys that plugin's mount point and notifies the user; the host page is unaffected |
| Frontend RPC | Rate limiting + message size limits + unknown message discard |
| Backend script | Subprocess timeout kill; a single failure only creates a RunLog; N consecutive failures set error |
| Backend host | Proxy loop on a dedicated thread + Flask app context; proxy exceptions return INTERNAL_ERROR to the script |
| Resources | Subprocess concurrency cap; extraction size/file count caps; RunLog/output truncation; log masking |
| Host API evolution | apiVersion negotiation + engines range check; incompatible plugins are rejected at install |
plugin_versions retains full history for rollback;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);apiVersion major-version negotiation; the host supports multiple versions side by side (starting with v1); breaking changes bump the major;The first release only supports uploading .stplugin.zip packages. The marketplace protocol is reserved:
// 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".
| Phase | Scope |
|---|---|
| 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) |
| P2 | Event subscription (rpc.subscribe), scheduled script triggers and plugin service identity |
| P3 | Marketplace (index protocol implementation + signature verification + marketplace page), inter-plugin dependencies |