# 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 (future) | | `db.write` | `db` | Write extension tables (future) | ### 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` ### 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 ---