This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/ICD/extensions.md
gobha 495bcc94f4 V0.38.4 full composition (#237)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-25 17:29:08 +00:00

20 KiB

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:

{
  "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):

{
    "method":   "POST",
    "path":     "/webhook",
    "headers":  {"content-type": "application/json", ...},
    "query":    {"page": "1", ...},
    "body":     "{...}",
    "user_id":  "uuid"
}

Expected return dict:

{"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:

{
  "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):

{
  "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.

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):

{
  "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:

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

  • 400starlark 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):

{
  "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:

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.

Connection Type Discovery (v0.38.4)

Auth: Authenticated user (no specific permission required)

GET /connection-types → {"data": [...connection type entries]}

Returns the merged set of connection types declared by all active packages. When multiple packages declare the same type name, library declarations take precedence over non-library declarations.

Response entry:

{
  "type": "gitea",
  "label": "Gitea Instance",
  "package_id": "gitea-client",
  "package_title": "Gitea API Client",
  "fields": {
    "base_url":  {"type": "url",    "required": "true",  "label": "Server URL"},
    "api_token": {"type": "secret", "required": "true",  "label": "API Token"},
    "org":       {"type": "string", "required": "false", "label": "Default Org"}
  },
  "scopes": ["global", "team", "personal"]
}

Used by all three Connections management UIs (Settings, Admin, Team Admin) to populate the connection type dropdown. Replaces client-side manifest scanning which required admin permissions.


Library Composition (v0.38.4)

Libraries declare connection types in their manifest connections[] field. Consumers depend on the library and use its exported functions, passing connection dicts obtained from connections.get().

End-to-end pattern:

  1. Library declares connections: [{ "type": "gitea", ... }] in manifest
  2. Library exports functions that accept a conn parameter
  3. Admin installs library and grants permissions (api.http, connections.read, etc.)
  4. User creates a connection of the library's type via Settings > Connections
  5. Consumer declares dependencies: { "gitea-client": ">=1.0.0" } in manifest
  6. Consumer loads library: gitea = lib.load("gitea-client")
  7. Consumer resolves connection: conn = connections.get("gitea")
  8. Consumer calls library: gitea.get_repos(conn)

The library function executes with the library's permission context. The consumer does not need api.http or db.* permissions — those are the library's concern.

Browser-side consumers can also call the library's REST endpoints directly (GET /s/gitea-client/api/repos) without Starlark, enabling pure type: "surface" packages.


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:

{ "dependencies": { "gitea-client": ">=1.0.0" } }

Then load at runtime:

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:

{
  "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_idCreate with is_system: true
  • Same version → skip (idempotent)
  • Different versionUpdate manifest, name, description, author