# Extensions Plugin system with three tiers: Browser JS (client-side), Starlark sandbox (server-side, v0.29.0), Sidecar containers (server-side, future). ### User Extensions **Auth:** Authenticated user ``` GET /extensions → {"data": [...UserExtension]} ?tier=browser (optional filter by tier) POST /extensions/:id/settings ← {"is_enabled": bool, "settings": {...}} :id = extension UUID → {"ok": true} GET /extensions/:id/manifest → {"data": } :id = ext_id (manifest id) GET /extensions/tools → {"data": [...tool schema objects]} ``` **Notes:** - `GET /extensions` returns `UserExtension` objects: the base extension fields plus `user_enabled` and `user_settings` overrides. - System extensions (`is_system: true`) cannot be disabled by users. Attempting to set `is_enabled: false` on a system extension returns 403. - `GET /extensions/tools` returns raw tool schema JSON from all enabled browser extensions' `manifest.tools[]` arrays. ### Extension API Routes (v0.29.1) Starlark packages serve custom JSON endpoints. Mounted at `/s/:slug/api/*path` with JWT authentication. **Auth:** Authenticated user (JWT — returns 401, not redirect) ``` ANY /s/:slug/api/*path → Starlark on_request(req) response ``` **Route:** `slug` is the package ID. `path` is matched against the manifest's `api_routes` declaration. **Manifest `api_routes` field:** ```json { "api_routes": [ {"method": "GET", "path": "/status"}, {"method": "POST", "path": "/webhook"}, {"method": "*", "path": "/proxy/*"} ] } ``` - `method`: exact match (case-insensitive), or `"*"` for any method - `path`: exact match, or trailing `"*"` for prefix match - Boolean `true` shorthand: all routes forwarded - Missing or `false`: no routes served **Request dict passed to `on_request(req)`:** ```python { "method": "POST", "path": "/webhook", "headers": {"content-type": "application/json", ...}, "query": {"page": "1", ...}, "body": "{...}", "user_id": "uuid" } ``` **Expected return dict:** ```python {"status": 200, "headers": {"X-Custom": "val"}, "body": "{...}"} ``` - `status`: int (default 200). Return `None` for 204 No Content. - `headers`: dict (optional). Content-Type auto-detected if not set. - `body`: string (default ""). **Validation pipeline:** 1. Package exists and is enabled 2. Status is `active` (not `pending_review` or `suspended`) 3. Tier is `starlark` 4. `api.http` permission is granted 5. Method+path matches `api_routes` in manifest **Errors:** - `401` — no/invalid JWT - `403` — package suspended or missing `api.http` permission - `404` — package not found, disabled, or route not declared - `400` — package is not starlark tier - `500` — Starlark execution error ### Admin Extension Management **Auth:** Admin role ``` GET /admin/extensions → {"data": [...Extension]} POST /admin/extensions ← {ext_id*, name*, version?, tier?, description?, author?, manifest?, is_system?, is_enabled?} → {"data": Extension} (201) PUT /admin/extensions/:id ← {name?, version?, description?, :id = extension UUID author?, is_system?, is_enabled?, manifest?} → {"data": Extension} DELETE /admin/extensions/:id → {"ok": true} :id = extension UUID ``` **Install defaults:** `version` → `"0.0.0"`, `tier` → `"browser"`, `manifest` → `{}`, `scope` → `"global"`. **Tier validation:** `tier` must be one of: `browser`, `starlark`, `sidecar`. **Duplicate rejection:** If `ext_id` is already installed, returns 409. ### Asset Serving **Auth:** None (public — script tags can't send Authorization headers) ``` GET /extensions/:id/assets/*path → application/javascript :id = ext_id (manifest id) ``` Returns the inline `_script` field from the extension's manifest. The `*path` segment is accepted but currently ignored (all requests return the same script). Disabled extensions return 404. ### Extension Permissions (v0.29.0+) Starlark packages declare capabilities in `manifest.permissions`. Admin must grant each before the package activates. | Permission | Module | Description | |-----------|--------|-------------| | `secrets.read` | `secrets` | Read extension secrets via GlobalConfig | | `notifications.send` | `notifications` | Send in-app notifications | | `filters.pre_completion` | — | Register pre-completion filter | | `api.http` | `http` | Outbound HTTP requests (v0.29.0: module, v0.29.1: also required for API routes) | | `provider.complete` | `provider` | LLM completion calls via BYOK chain (v0.29.1) | | `db.read` | `db` | Read extension tables and platform views (v0.29.2) | | `db.write` | `db` | Write extension tables (v0.29.2) | ### Starlark Modules (v0.29.0+) Modules injected into the script namespace based on granted permissions: **`secrets`** (requires `secrets.read`): - `secrets.get(key)` → string or None - `secrets.list()` → list of key names **`notifications`** (requires `notifications.send`): - `notifications.send(user_id, title, body?, type?)` → True **`http`** (requires `api.http`, v0.29.1): - `http.get(url, headers?)` → `{"status": int, "headers": dict, "body": string}` - `http.post(url, body?, headers?)` → response dict - `http.put(url, body?, headers?)` → response dict - `http.delete(url, headers?)` → response dict - `http.request(method, url, body?, headers?)` → response dict - SSRF protection: private/loopback/link-local IPs blocked after DNS - Manifest `network_access`: `{"allow": [...]}` or `{"block": [...]}` **`provider`** (requires `provider.complete`, v0.29.1): - `provider.complete(messages, model?, max_tokens?, temperature?)` → response dict - Response: `{"content", "model", "finish_reason", "input_tokens", "output_tokens"}` - Provider resolved via BYOK chain; pinnable via `requires_provider.provider_config_id` **`db`** (requires `db.read` or `db.write`, v0.29.2): - `db.query(table, filters=None, order=None, limit=100)` → list of dicts - `table`: logical name (physical: `ext_{pkg_slug}_{table}`) - `filters`: dict of `{column: value}` equality filters (optional) - `order`: `"col"` or `"-col"` for descending (optional) - `limit`: max rows (default 100) - `db.insert(table, row_dict)` → inserted row dict with auto-generated `id` (`db.write`) - `db.update(table, id, partial_dict)` → True (`db.write`) - `db.delete(table, id)` → True (`db.write`) - `db.list_tables()` → list of logical table names for this package - `db.view(view_name, filters=None, limit=100)` → list of dicts - Allowed view names: `"users"` → `ext_view_users`, `"channels"` → `ext_view_channels` - `ext_view_users`: `id`, `display_name`, `email` - `ext_view_channels`: `id`, `title`, `type`, `team_id` ### Extension Database Tables (v0.29.2) Starlark packages declare owned tables in `manifest.db_tables`. Tables are created on install and dropped on uninstall. **Manifest `db_tables` field:** ```json { "db_tables": { "logs": { "columns": { "message": "text", "user_id": "text", "count": "int", "score": "real", "active": "bool", "created_at": "timestamp" }, "indexes": [ ["user_id"], ["user_id", "created_at"] ] } } } ``` - **Physical name**: `ext_{pkg_slug}_{logical_name}` (hyphens → underscores) - **Auto-columns**: `id TEXT PRIMARY KEY` (UUID generated on insert), `created_at` (dialect-correct timestamp default) - **Column types**: `text`, `int`/`integer`, `real`/`float`, `bool`/`boolean`, `timestamp` — mapped to dialect-correct SQL - **Indexes**: each entry is a list of columns for a composite index - **Dialect**: PG uses `TIMESTAMPTZ`/`BOOLEAN`; SQLite uses `TEXT`/`INTEGER` - **Catalog**: tracked in `ext_data_tables` per package for lifecycle management ### Extension Tools (v0.29.2) Starlark packages declare server-side tools in `manifest.tools`. These are included in `BuildToolDefs` alongside server tools. The completion tool loop dispatches matched calls to the `on_tool_call` entry point. **Manifest `tools` field (starlark tier only):** ```json { "tier": "starlark", "permissions": ["db.read"], "tools": [ { "name": "search_logs", "description": "Search extension log entries", "parameters": { "type": "object", "properties": { "query": {"type": "string"}, "user_id": {"type": "string"} }, "required": ["query"] } } ] } ``` **`on_tool_call(call)` entry point:** Called by the completion tool loop when a tool declared in `tools` is invoked by the LLM. ```python def on_tool_call(call): # call dict: # { # "tool_name": "search_logs", # "tool_call_id": "call_abc123", # "arguments": {"query": "hello", "user_id": "u1"} # } if call["tool_name"] == "search_logs": rows = db.query("logs", filters={"user_id": call["arguments"]["user_id"]}) return {"results": rows} return {"error": "unknown tool"} ``` - Return value is serialized to JSON and returned as the tool result - All sandbox modules (including `db`) are available per granted permissions - No additional permission is required beyond package being `active` ### Multi-File Starlark Packages (v0.38.0) Starlark packages support `load()` for splitting code across multiple files. The entry point script is `script.star` (or the `entry_point` manifest field). Submodules live in `star/`. **Archive structure:** ``` my-extension/ ├── manifest.json ← metadata only (no _starlark_script) ├── script.star ← entry point (required for starlark tier) ├── star/ ← optional submodules │ ├── auth.star │ ├── repos.star │ └── helpers.star ├── js/ ← optional surface assets └── css/ ``` **Manifest `entry_point` field (optional):** ```json { "tier": "starlark", "entry_point": "script.star" } ``` Default is `script.star`. The installer validates the entry point exists in the archive for starlark-tier packages (returns 400 if missing). **`load()` in Starlark scripts:** ```python # script.star load("star/auth.star", "auth_headers") load("star/repos.star", "get_repos") def on_request(req): headers = auth_headers(conn) repos = get_repos(conn) return {"status": 200, "body": json.encode(repos)} ``` **Constraints:** - Package-scoped: can only load files within the package's own directory - `.star` files only — cannot load `.js`, `.json`, etc. - Path traversal (`..`) and absolute paths are rejected - Circular dependencies are detected and rejected - Loaded files get the same injected modules (`db`, `http`, etc.) as the entry point - All loaded files share the same step limit budget (1M ops total) **Backward compatibility:** Existing packages with `_starlark_script` in their manifest continue to work. The runner tries disk first, then falls back to the manifest field. Reinstalling extracts `script.star` to disk. **Install errors:** - `400` — `starlark package missing entry point "script.star"` (or custom `entry_point` value) ## 5. Extension Connections (v0.38.1) Scoped credential management for extensions that integrate with external services. Same scope/resolution pattern as provider configs (personal → team → global). ### Personal Connections **Auth:** Authenticated user ``` GET /connections → {"data": [...connection summaries]} POST /connections ← {"type","package_id","name","config"} → {"id","type","name"} GET /connections/:id → connection summary (owned only) PUT /connections/:id ← {"name?","config?"} → {"message":"connection updated"} DELETE /connections/:id → {"message":"connection deleted"} GET /connections/resolve → resolved connection (decrypted config) ?type=gitea&name=Work+Gitea ``` ### Team Connections **Auth:** Team admin (RequireTeamAdmin middleware) ``` GET /teams/:teamId/connections → {"data": [...connection summaries]} POST /teams/:teamId/connections ← {"type","package_id","name","config"} → {"id","type","name"} PUT /teams/:teamId/connections/:id ← {"name?","config?"} → {"message":"connection updated"} DELETE /teams/:teamId/connections/:id → {"message":"connection deleted"} ``` ### Admin (Global) Connections **Auth:** Admin ``` GET /admin/connections → {"data": [...connection summaries]} POST /admin/connections ← {"type","package_id","name","config"} → {"id","type","name"} PUT /admin/connections/:id ← {"name?","config?"} → {"message":"connection updated"} DELETE /admin/connections/:id → {"message":"connection deleted"} ``` **Connection summary** (list/get responses — secrets masked): ```json { "id": "uuid", "type": "gitea", "package_id": "git-board", "scope": "personal", "name": "Work Gitea", "is_active": true, "has_config": true, "created_at": "2026-03-25T...", "updated_at": "2026-03-25T..." } ``` **Scope resolution:** `GET /connections/resolve?type=gitea` resolves via personal → team → global chain. Returns full connection with decrypted config. Optional `name` parameter for named resolution. **Config encryption:** Fields with `type: "secret"` in the package manifest's `connections[].fields` are encrypted at rest using the same vault pattern as provider API keys. The handler layer encrypts on write and decrypts on read. The store is encryption-agnostic. **Unique constraint:** `(type, scope, owner_id, name)` — a user cannot have two connections of the same type with the same name. **Starlark module:** Extensions with `connections.read` permission get a `connections` module: ```python conn = connections.get("gitea") # scope chain conn = connections.get("gitea", name="Work Gitea") # named all = connections.list("gitea") # all accessible ``` Each returned dict contains `id`, `type`, `name`, `scope` plus all config fields with secrets decrypted. --- ### Library Packages (v0.38.2) Library packages (`type: "library"`) export Starlark functions for other packages to consume. Libraries run with their own permission context, isolating consumers from implementation details. **Manifest fields:** - `type: "library"` — required - `tier: "starlark"` — required (libraries are always starlark-tier) - `exports: ["fn_a", "fn_b"]` — required, names of globals to expose - `permissions: [...]` — the library's own permissions (not inherited by consumers) - `dependencies: {"other-lib": ">=1.0.0"}` — optional, libraries can depend on other libraries **Restrictions:** Libraries cannot have `tools`, `pipes`, or a `route`. **Consumer usage (Starlark):** Consumers declare dependencies in their manifest: ```json { "dependencies": { "gitea-client": ">=1.0.0" } } ``` Then load at runtime: ```python gitea = lib.load("gitea-client") repos = gitea.get_repos(conn) ``` `lib.load()` validates the dependency record, loads the library's script with the library's own permissions, extracts the declared exports, and returns them as a frozen struct. Results are cached per invocation. **Admin endpoints:** | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/admin/packages/:id/dependencies` | Libraries this package depends on | | GET | `/api/v1/admin/packages/:id/consumers` | Packages that depend on this library | | GET | `/api/v1/admin/dependencies` | Full dependency graph | **Uninstall protection:** Libraries with active consumers return 409 on delete. --- ### Config Sections (v0.38.3) Packages can declare a `config_section` in their manifest to inject a Preact configuration component into the Settings, Admin, or Team Admin surfaces. This enables headless extensions and libraries to own their configuration UX without requiring a full surface. **Manifest schema:** ```json { "config_section": { "label": "My Extension", "icon": "M12 2L2 7...", "component": "js/config.js", "surfaces": ["admin", "settings", "team-admin"], "category": "system" } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `label` | string | Yes | Nav link text. Falls back to package title if empty. | | `icon` | string | No | SVG path data in compact format (admin CatIcon). | | `component` | string | No | Relative path within the package archive. Default: `js/config.js`. | | `surfaces` | string[] | Yes | Target surfaces: `admin`, `settings`, `team-admin`. | | `category` | string | No | Admin-only: category tab to appear under. Default: `system`. | **Component contract:** The config section component is a standard ES module exporting a default Preact component. It is lazy-loaded via dynamic `import()` from the existing asset-serving endpoint (`GET /surfaces/:id/:component`). The component uses `sw.sdk` to read/write its own package settings: - `sw.api.admin.packages.settings(packageId)` — read - `sw.api.admin.packages.updateSettings(packageId, data)` — write Settings are stored in the `package_settings` JSONB column (admin scope) and `package_user_settings` table (user scope). **Discovery:** At page load, the backend queries enabled packages for `config_section` entries targeting the current surface and passes them as `__CONFIG_SECTIONS__` to the frontend. The surface merges them into its nav and section module map. **No new tables or endpoints.** Config sections are purely manifest-driven, using existing package storage and settings infrastructure. --- ### Builtin Seeding On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/` for subdirectories containing `manifest.json` + `script.js`. Each is upserted as a system extension: - **New ext_id** → `Create` with `is_system: true` - **Same version** → skip (idempotent) - **Different version** → `Update` manifest, name, description, author ---