Files

9.3 KiB

Plugin deployment

Uploading a bundle plugin, enabling it, and managing permissions.

Upload — POST /api/plugin/:pluginId/upload-bundle

Upload the built bundle to the host. Uses multipart/form-data. The pluginId in the URL must match the id in plugin.json.

Preferred (handles entry + chunks + maps + css automatically):

TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
  node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d .

Raw curl equivalent (legacy single-file form still works):

PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
  -F "js=@dist/main.js" \
  -F "css=@dist/styles.css" \
  -F "manifest=@plugin.json" \
  "http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"

Auth note: Supports both session cookies (auth_session) and Personal Access Tokens (Authorization: Bearer <PAT>). Runtime asset GETs (/api/plugin/:id/runtime/*) are session-only (PAT gets 403 by design) — verify those with a signed cookie, not a PAT.

Fields (v2 multi-asset, since 2026-10):

Field Required Max count Max size Description
js Yes 20 5 MB each Entry + lazy chunks. Entry = the file whose name matches manifest.entry (a lone differently-named js file still works — legacy fallback)
css No 5 1 MB each Stylesheets, concatenated under the manifest.style name
map No 20 5 MB each Source maps, served later as application/json
manifest Yes 1 10 KB plugin.json manifest
  • Total cap 15 MB across all assets.
  • Chunk filenames must be FLAT (x-<hash>.chunk.js) — the multipart transport strips directory parts from filenames.
  • The bundle dir on disk (<backend-cwd>/.assets/plugins/bundle/<pluginId>/) is wiped on every upload — stale chunks are removed, and hand-patched files on disk are discarded.

Returns { pluginId, hash, size, assets } — assets is the stored file count (1 for legacy single-file uploads).

Registering a NEW plugin id: POST /api/plugin {"id": "<kebab-id>", ...} — PAT-accessible (@TokenAccess() since 2026-10) and custom ids require the plugin_developer whitelist. Without an explicit id the server mints a plgXXX id, which CANNOT receive bundles (manifest id must equal the DB id). Deleting a plugin (DELETE /api/plugin/:id) remains admin-only.

Security (7-layer validation):

  1. Authentication — valid session cookie or PAT required (401 if missing)
  2. Developer whitelist — plugin_developer table lookup (403 if not whitelisted)
  3. Rate limit — 10 uploads/min per user (429 if exceeded)
  4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB, 15 MB total
  5. Manifest validation — required fields, id regex [a-z0-9-]+, minAppVersion ≤ host version
  6. Content validation — the ENTRY must be IIFE format (prefix !function / (function / (() => / var <name>=<iife>); chunks get the forbidden-pattern check only (they are not IIFEs); JS rejects <script>/document.write/javascript:; CSS strips expression()/ @import/javascript: URIs; maps are name/size checked only (they embed original source text — pattern checks would false-positive)
  7. Integrity — entry SHA-256 stored in DB for audit + PluginAuditLog row (uploader/IP/UA); note the loader does NOT re-verify the hash at load time

Enable — PUT /api/base/:baseId/plugin-config/:pluginId

The plugin must be enabled before it can be installed to any position.

curl -s -b "auth_session=$SESSION" -X PUT \
  "http://localhost:3003/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true,"tableIds":[]}'
  • tableIds: [] — enabled base-wide (all tables)
  • tableIds: ["tblX", "tblY"] — enabled only for specific tables

Install by position

ContextMenu

curl -s -b "auth_session=$SESSION" -X POST \
  "http://localhost:3003/api/table/$TABLE_ID/plugin-context-menu/install" \
  -H 'Content-Type: application/json' \
  -d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'

Body: pluginId (required), name (optional display name).

Panel

Panel install requires an existing panel. First create one, then install to it:

# Create a panel (if none exists)
PANEL_ID=$(curl -s -b "auth_session=$SESSION" -X POST \
  "http://localhost:3003/api/table/$TABLE_ID/plugin-panel" \
  -H 'Content-Type: application/json' \
  -d '{"name":"My Panel"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")

# Install plugin to the panel
curl -s -b "auth_session=$SESSION" -X POST \
  "http://localhost:3003/api/table/$TABLE_ID/plugin-panel/$PANEL_ID/install" \
  -H 'Content-Type: application/json' \
  -d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'

The plg prefixed ID is the panel's UUID, not a plugin ID. Create a panel first if you don't already have one.

Dashboard

curl -s -b "auth_session=$SESSION" -X POST \
  "http://localhost:3003/api/base/$BASE_ID/dashboard/$DASHBOARD_ID/plugin" \
  -H 'Content-Type: application/json' \
  -d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'

View

curl -s -b "auth_session=$SESSION" -X POST \
  "http://localhost:3003/api/table/$TABLE_ID/view/plugin" \
  -H 'Content-Type: application/json' \
  -d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'

Returns { pluginId, pluginInstallId, name, viewId }. Creates a new view of type plugin in the table's view list. The plugin's definePlugin.onLoad must call ctx.addView(id, component) to render the component as a view. Authority Matrix entries are auto-created for the new install (default denied for non-owner roles).

Route (full-page app, since 2026-10)

The host renders a dedicated page for the plugin at /base/<baseId>/p/<pluginId> — the whole main-area viewport inside the base layout. The plugin registers via ctx.addRoute(id, component) and typically brings its own router (HashRouter recommended, so Next.js routing is untouched).

curl -s -H "Authorization: Bearer $TEABLE_PAT" -X POST \
  "http://localhost:3003/api/base/$BASE_ID/plugin-installs" \
  -H 'Content-Type: application/json' \
  -d '{"pluginId":"$PLUGIN_ID","name":"My App","position":"route"}'

Notes (verified 2026-10 on plugin-showcase):

  • manifest.positions must include "route".
  • The page itself does not gate on plugin-config enablement — the URL is the access point. Follow the standard enable+install flow anyway so the plugin shows up in management UIs and future permission layers behave uniformly.
  • Host SDK hooks (e.g. host.SDK.useBaseId()) work on the route page because it renders inside BaseLayout's provider stack.

Authority Matrix (plugin permissions)

Plugins integrate with the 4-layer permission system. Three control layers:

Layer What it controls Mechanism
Plugin visible User sees plugin in context menu / panel / dashboard AuthorityMatrixRolePluginInstall per-role per-install
Plugin executable User can click and run the plugin Checked at PluginSlot render
Data operations Plugin's CRUD respects user's permissions Already protected — plugins call @teable/openapi which passes through existing AM enforcement

Default behavior:

  • New installs default to disabled for non-owner roles
  • Base owner always has full access
  • Admin enables per role through the RoleEditor UI

Developer whitelist

Only users in the plugin_developer table can upload bundles. To add a developer:

INSERT INTO plugin_developer (id, user_id, email, created_by)
VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');

Prefer a PAT (upload-bundle accepts Authorization: Bearer <PAT>). For a session cookie, sign in with any real account created for the purpose (POST /api/auth/signup), then add its plugin_developer row before uploading:

curl -s -c /tmp/cookie.txt http://localhost:3003/api/auth/signin \
  -X POST -H 'Content-Type: application/json' \
  -d '{"email":"<real-account>","password":"<password>"}'
SESSION=$(grep auth_session /tmp/cookie.txt | awk '{print $NF}')

Full deploy script (copy-paste)

#!/bin/bash
set -e
DIR="${1:-.}"
UPLOAD_URL="${UPLOAD_URL:-http://localhost:3003}"
BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"

# Read plugin ID from manifest
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")

# Upload: teable-plugin deploy handles entry + chunks + maps + css.
# Raw-curl equivalent for a single-file bundle:
#   curl -s -H "Authorization: Bearer $TEABLE_PAT" \
#     -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
#     -F "manifest=@$DIR/plugin.json" \
#     "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
  node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
echo "Uploaded: $PLUGIN_ID"

# Enable
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X PUT \
  "$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true,"tableIds":[]}' > /dev/null
echo "Enabled: $PLUGIN_ID"