# 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": <manifest JSON>}
     :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`

### 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

---
