@palettelab/cli 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ """Palette Platform SDK for building backend plugins."""
2
+
3
+ from palette_sdk.plugin_router import PluginRouter
4
+ from palette_sdk.plugin_context import PluginContext, get_plugin_context
5
+ from palette_sdk.tool_definition import ToolDefinition
6
+ from palette_sdk.manifest import PluginManifest, load_manifest
7
+ from palette_sdk.schemas import (
8
+ SuccessResponse,
9
+ ErrorResponse,
10
+ PaginatedResponse,
11
+ )
12
+ from palette_sdk.db import OrgScopedTable, PluginBase, ensure_org_rls
13
+ from palette_sdk.permissions import (
14
+ KNOWN_PERMISSIONS,
15
+ is_known_permission,
16
+ require_permission,
17
+ )
18
+ from palette_sdk.events import Event, subscribe_event
19
+ from palette_sdk.config import get_config, require_config
20
+ from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
21
+ from palette_sdk.testing import route_permission_issues
22
+
23
+ __all__ = [
24
+ "PluginRouter",
25
+ "PluginContext",
26
+ "get_plugin_context",
27
+ "ToolDefinition",
28
+ "PluginManifest",
29
+ "load_manifest",
30
+ "SuccessResponse",
31
+ "ErrorResponse",
32
+ "PaginatedResponse",
33
+ "OrgScopedTable",
34
+ "PluginBase",
35
+ "ensure_org_rls",
36
+ "KNOWN_PERMISSIONS",
37
+ "is_known_permission",
38
+ "require_permission",
39
+ "Event",
40
+ "subscribe_event",
41
+ "get_config",
42
+ "require_config",
43
+ "sign_webhook",
44
+ "verify_webhook_signature",
45
+ "route_permission_issues",
46
+ ]
47
+
48
+ __version__ = "0.1.3"
@@ -0,0 +1,21 @@
1
+ """Install-scoped plugin configuration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypeVar
6
+
7
+ from palette_sdk.plugin_context import PluginContext
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def get_config(ctx: PluginContext, key: str, default: T | None = None) -> Any | T | None:
13
+ """Return a config value injected by the platform for this org install."""
14
+ return ctx.config.get(key, default)
15
+
16
+
17
+ def require_config(ctx: PluginContext, key: str) -> Any:
18
+ """Return a config value or raise a clear runtime error."""
19
+ if key not in ctx.config:
20
+ raise KeyError(f"missing required plugin config key: {key}")
21
+ return ctx.config[key]
@@ -0,0 +1,38 @@
1
+ """Database primitives for Palette plugins.
2
+
3
+ Plugins that declare a `database` block in their manifest get their own
4
+ Postgres schema, owned by a dedicated low-privilege role, with row-level
5
+ security enforcing per-organization isolation.
6
+
7
+ Typical plugin usage:
8
+
9
+ # models.py
10
+ from palette_sdk.db import OrgScopedTable
11
+ from sqlalchemy import String
12
+ from sqlalchemy.orm import Mapped, mapped_column
13
+
14
+ class Expense(OrgScopedTable):
15
+ __tablename__ = "expenses"
16
+ title: Mapped[str] = mapped_column(String(200))
17
+ amount_cents: Mapped[int]
18
+
19
+ # migrations/versions/0001_init.py
20
+ from alembic import op
21
+ import sqlalchemy as sa
22
+ from palette_sdk.db import ensure_org_rls
23
+
24
+ def upgrade():
25
+ op.create_table(
26
+ "expenses",
27
+ sa.Column("id", sa.Integer, primary_key=True),
28
+ sa.Column("organization_id", sa.BigInteger, nullable=False, index=True),
29
+ sa.Column("title", sa.String(200), nullable=False),
30
+ sa.Column("amount_cents", sa.Integer, nullable=False),
31
+ )
32
+ ensure_org_rls(op, "expenses")
33
+ """
34
+
35
+ from palette_sdk.db.base import OrgScopedTable, PluginBase
36
+ from palette_sdk.db.rls import ensure_org_rls
37
+
38
+ __all__ = ["OrgScopedTable", "PluginBase", "ensure_org_rls"]
@@ -0,0 +1,89 @@
1
+ """Alembic env template for plugin migrations.
2
+
3
+ Plugins ship an `env.py` that is a one-liner:
4
+
5
+ from palette_sdk.db.alembic_env import run_migrations
6
+ run_migrations()
7
+
8
+ The runner reads three environment variables set by the platform when it
9
+ invokes Alembic:
10
+
11
+ - ``PALETTE_PLUGIN_ID`` — plugin id; determines the target schema
12
+ (``app_<sanitized_id>``) and version table schema.
13
+ - ``PALETTE_DB_URL`` — SQLAlchemy async URL for the plugin's DB.
14
+ - ``PALETTE_PLUGIN_ROLE`` (optional) — role the migration should run as;
15
+ defaults to the connecting user (so DDL works).
16
+
17
+ Plugin devs running ``alembic upgrade head`` locally without those vars get
18
+ a helpful error rather than a scribbled-on main DB.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import os
25
+ import re
26
+ from logging.config import fileConfig
27
+
28
+ from alembic import context
29
+ from sqlalchemy import pool, text
30
+ from sqlalchemy.ext.asyncio import create_async_engine
31
+
32
+
33
+ def _plugin_schema(plugin_id: str) -> str:
34
+ safe = re.sub(r"[^a-z0-9_]", "_", plugin_id.lower())
35
+ return f"app_{safe}"
36
+
37
+
38
+ def _do_run_migrations(connection, schema: str, plugin_role: str | None) -> None:
39
+ if plugin_role:
40
+ connection.execute(text(f'SET ROLE "{plugin_role}"'))
41
+ connection.execute(text(f'SET search_path TO "{schema}"'))
42
+
43
+ context.configure(
44
+ connection=connection,
45
+ target_metadata=None,
46
+ version_table="alembic_version",
47
+ version_table_schema=schema,
48
+ include_schemas=False,
49
+ )
50
+ with context.begin_transaction():
51
+ context.run_migrations()
52
+
53
+
54
+ async def _run_async(schema: str, db_url: str, plugin_role: str | None) -> None:
55
+ connectable = create_async_engine(db_url, poolclass=pool.NullPool)
56
+ try:
57
+ # `.begin()` (not `.connect()`): commits on clean exit, rolls back on
58
+ # exception. With `.connect()` the surrounding transaction is rolled
59
+ # back at close time, silently discarding every CREATE/ALTER the
60
+ # migration issued.
61
+ async with connectable.begin() as connection:
62
+ await connection.run_sync(
63
+ lambda sync_conn: _do_run_migrations(sync_conn, schema, plugin_role)
64
+ )
65
+ finally:
66
+ await connectable.dispose()
67
+
68
+
69
+ def run_migrations() -> None:
70
+ plugin_id = os.environ.get("PALETTE_PLUGIN_ID")
71
+ db_url = os.environ.get("PALETTE_DB_URL")
72
+ plugin_role = os.environ.get("PALETTE_PLUGIN_ROLE") or None
73
+
74
+ if not plugin_id or not db_url:
75
+ raise RuntimeError(
76
+ "Plugin migrations require PALETTE_PLUGIN_ID and PALETTE_DB_URL "
77
+ "to be set. These are provided automatically by the platform; "
78
+ "use `palette dev` for local runs."
79
+ )
80
+
81
+ config = context.config
82
+ if config.config_file_name:
83
+ try:
84
+ fileConfig(config.config_file_name)
85
+ except Exception:
86
+ pass
87
+
88
+ schema = _plugin_schema(plugin_id)
89
+ asyncio.run(_run_async(schema, db_url, plugin_role))
@@ -0,0 +1,50 @@
1
+ """Declarative bases for plugin models.
2
+
3
+ `OrgScopedTable` is the base every plugin table must inherit from. It adds
4
+ `organization_id` automatically and registers the class so migration tooling
5
+ and the build linter can verify RLS is in place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from sqlalchemy import BigInteger, Index
11
+ from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
12
+
13
+
14
+ class PluginBase(DeclarativeBase):
15
+ """Declarative base for plugin models.
16
+
17
+ Prefer `OrgScopedTable` for any table that stores org data. Use `PluginBase`
18
+ directly only for tables that are genuinely global to the plugin (e.g. a
19
+ lookup table seeded by a migration). Any such use still lives inside the
20
+ plugin's own schema and is not cross-org by design.
21
+ """
22
+
23
+
24
+ class OrgScopedTable(PluginBase):
25
+ """Base for all per-organization plugin tables.
26
+
27
+ Inheriting from this gives you:
28
+ - An `organization_id BIGINT NOT NULL` column with an index.
29
+ - A row-level security policy, enforced at migration time via
30
+ `ensure_org_rls(op, "<tablename>")` in your Alembic migration.
31
+
32
+ Never write `WHERE organization_id = ?` in your queries — the database
33
+ will filter rows for you based on the session variable set by the
34
+ platform at request time.
35
+ """
36
+
37
+ __abstract__ = True
38
+
39
+ @declared_attr
40
+ def organization_id(cls) -> Mapped[int]:
41
+ return mapped_column(BigInteger, nullable=False, index=True)
42
+
43
+ @declared_attr.directive
44
+ def __table_args__(cls):
45
+ tablename = getattr(cls, "__tablename__", None)
46
+ if not tablename:
47
+ return ()
48
+ return (
49
+ Index(f"ix_{tablename}_org", "organization_id"),
50
+ )
@@ -0,0 +1,34 @@
1
+ """Row-level security helpers for plugin migrations.
2
+
3
+ Every plugin table that holds org data must call `ensure_org_rls(op, table)`
4
+ in the migration that creates it. This enables RLS and installs the standard
5
+ policy, which filters rows by the `app.current_org_id` session variable set
6
+ by the platform at request time.
7
+
8
+ The `palette build` CLI lints migrations and will reject any that:
9
+ - create an org table without a matching `ensure_org_rls` call,
10
+ - contain `DROP POLICY` or `DISABLE ROW LEVEL SECURITY`,
11
+ - reference the `public.` schema,
12
+ - set `FORCE ROW LEVEL SECURITY` off.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ ORG_POLICY_NAME = "org_isolation"
20
+
21
+
22
+ def ensure_org_rls(op: Any, table_name: str, *, policy_name: str = ORG_POLICY_NAME) -> None:
23
+ """Enable RLS on `table_name` and install the org-isolation policy.
24
+
25
+ Idempotent: safe to call even if the policy already exists.
26
+ """
27
+ op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
28
+ op.execute(f'ALTER TABLE "{table_name}" FORCE ROW LEVEL SECURITY')
29
+ op.execute(f'DROP POLICY IF EXISTS "{policy_name}" ON "{table_name}"')
30
+ op.execute(
31
+ f'CREATE POLICY "{policy_name}" ON "{table_name}" '
32
+ f"USING (organization_id = current_setting('app.current_org_id', true)::bigint) "
33
+ f"WITH CHECK (organization_id = current_setting('app.current_org_id', true)::bigint)"
34
+ )
@@ -0,0 +1,54 @@
1
+ """Plugin-side event subscribers.
2
+
3
+ Plugins declare in-process handlers for platform events by calling
4
+ `subscribe_event(topic, handler)` at import time (typically from the plugin's
5
+ backend entrypoint). The platform's plugin loader picks these up after
6
+ importing the plugin module and registers them with the core event bus.
7
+
8
+ Usage::
9
+
10
+ from palette_sdk.events import subscribe_event, Event
11
+
12
+ async def on_task_created(event: Event) -> None:
13
+ print(event.payload["task_id"])
14
+
15
+ subscribe_event("task.created", on_task_created)
16
+
17
+ Handlers run in the platform process — they share the asyncio loop with
18
+ request handlers. Do not block; offload heavy work to background tasks or
19
+ emit your own outbound HTTP call.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Awaitable, Callable
25
+ from dataclasses import dataclass
26
+ from datetime import datetime
27
+ from typing import Any
28
+
29
+ Handler = Callable[["Event"], Awaitable[None]]
30
+
31
+
32
+ @dataclass
33
+ class Event:
34
+ topic: str
35
+ organization_id: int | None
36
+ payload: dict[str, Any]
37
+ occurred_at: datetime
38
+
39
+
40
+ # Plugin-declared subscribers are captured here during module import; the
41
+ # platform drains the list after loading each plugin and attaches them to the
42
+ # core bus with the plugin's id.
43
+ _pending: list[tuple[str, Handler]] = []
44
+
45
+
46
+ def subscribe_event(topic: str, handler: Handler) -> None:
47
+ _pending.append((topic, handler))
48
+
49
+
50
+ def drain_pending() -> list[tuple[str, Handler]]:
51
+ """Platform-only. Return + clear the list of subscribers registered since last drain."""
52
+ out = list(_pending)
53
+ _pending.clear()
54
+ return out
@@ -0,0 +1,112 @@
1
+ """Plugin manifest loader and validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class AgentDefinition(BaseModel):
13
+ name: str
14
+ field: str
15
+ description: str
16
+ long_description: str = ""
17
+ category: str = "general"
18
+ tools: list[str] = Field(default_factory=list)
19
+ icon_name: str = "Robot"
20
+ color: str = "#6366f1"
21
+
22
+
23
+ class ToolEntry(BaseModel):
24
+ name: str
25
+ description: str = ""
26
+ entry: str
27
+
28
+
29
+ class FrontendEntry(BaseModel):
30
+ entry: str
31
+ sandbox: bool = True
32
+
33
+
34
+ class BackendEntry(BaseModel):
35
+ entry: str
36
+ routes_prefix: str | None = None
37
+
38
+
39
+ class GradientConfig(BaseModel):
40
+ bg: str
41
+ text: str = "#fff"
42
+
43
+
44
+ class SdkCompat(BaseModel):
45
+ frontend: str | None = None
46
+ backend: str | None = None
47
+
48
+
49
+ class PlatformCompat(BaseModel):
50
+ min_version: str | None = None
51
+ max_version: str | None = None
52
+
53
+
54
+ class Capabilities(BaseModel):
55
+ frontend: bool = False
56
+ backend: bool = False
57
+ database: bool = False
58
+ webhooks: bool = False
59
+ scheduled_jobs: bool = False
60
+ file_uploads: bool = False
61
+ external_network: list[str] = Field(default_factory=list)
62
+
63
+
64
+ class ScheduledJob(BaseModel):
65
+ name: str
66
+ schedule: str
67
+ handler: str
68
+
69
+
70
+ class RateLimit(BaseModel):
71
+ per_minute: int = 60
72
+
73
+
74
+ class PluginManifest(BaseModel):
75
+ """Validated plugin manifest from palette-plugin.json."""
76
+
77
+ manifest_version: Literal["1"] = "1"
78
+ id: str
79
+ name: str
80
+ version: str = "1.0.0"
81
+ developer: str = ""
82
+ category: str = "Productivity"
83
+ tagline: str = ""
84
+ description: str = ""
85
+ icon: str = "Puzzle"
86
+ gradient: GradientConfig = GradientConfig(bg="linear-gradient(135deg, #6366F1, #8B5CF6)")
87
+ sdk: SdkCompat | None = None
88
+ platform: PlatformCompat | None = None
89
+ capabilities: Capabilities = Field(default_factory=Capabilities)
90
+ public_routes: list[str] = Field(default_factory=list)
91
+ scheduled_jobs: list[ScheduledJob] = Field(default_factory=list)
92
+ rate_limit: RateLimit | None = None
93
+ frontend: FrontendEntry | None = None
94
+ backend: BackendEntry | None = None
95
+ agents: list[AgentDefinition] = Field(default_factory=list)
96
+ tools: list[ToolEntry] = Field(default_factory=list)
97
+ permissions: list[str] = Field(default_factory=list)
98
+ rating: float = 0.0
99
+ reviews: int = 0
100
+ featured: bool = False
101
+
102
+
103
+ def load_manifest(plugin_dir: str | Path) -> PluginManifest:
104
+ """Load and validate a palette-plugin.json from a plugin directory."""
105
+ manifest_path = Path(plugin_dir) / "palette-plugin.json"
106
+ if not manifest_path.exists():
107
+ raise FileNotFoundError(f"No palette-plugin.json found in {plugin_dir}")
108
+
109
+ with open(manifest_path) as f:
110
+ data = json.load(f)
111
+
112
+ return PluginManifest(**data)
@@ -0,0 +1,94 @@
1
+ """Plugin permission enforcement.
2
+
3
+ Plugins declare required permissions in their manifest. At route level they
4
+ call `require_permission("resource:action")` as a dependency — the platform
5
+ injected `request.state.plugin_permissions` (from the manifest) is checked
6
+ against the required permission. Mismatch → 403.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import Depends, HTTPException, Request, status
12
+
13
+ # Known permission vocabulary. Platform code uses this to validate manifests;
14
+ # plugin authors pick from this list. Extending the list is a platform change.
15
+ KNOWN_PERMISSIONS: frozenset[str] = frozenset(
16
+ {
17
+ "tasks:read",
18
+ "tasks:write",
19
+ "data_rooms:read",
20
+ "data_rooms:write",
21
+ "agents:read",
22
+ "agents:write",
23
+ "chat:read",
24
+ "chat:write",
25
+ "routines:read",
26
+ "routines:write",
27
+ "members:read",
28
+ "resources:read",
29
+ "resources:write",
30
+ }
31
+ )
32
+
33
+
34
+ def is_known_permission(perm: str) -> bool:
35
+ return perm in KNOWN_PERMISSIONS
36
+
37
+
38
+ #: Sentinel attribute the platform loader looks for when walking a route's
39
+ #: dependency tree. Its presence on the `_check` closure is what proves the
40
+ #: route is permission-gated.
41
+ _PALETTE_PERMISSION_ATTR = "__palette_permission__"
42
+
43
+
44
+ def require_permission(permission: str):
45
+ """Return a FastAPI dependency that asserts the plugin was granted `permission`.
46
+
47
+ Usage (inside a plugin's FastAPI router)::
48
+
49
+ @router.get("/my-endpoint", dependencies=[Depends(require_permission("tasks:read"))])
50
+ async def handler(...):
51
+ ...
52
+
53
+ Every plugin route MUST be gated by at least one `require_permission` call
54
+ (or be listed in the manifest's `public_routes`). The platform inspects the
55
+ dependency tree at mount time and refuses to load the plugin otherwise.
56
+
57
+ Raises 403 if the plugin's manifest doesn't list the permission. Raises 500
58
+ if the request wasn't dispatched through the plugin loader (no
59
+ plugin_permissions in request.state) — that indicates a misconfigured
60
+ deployment.
61
+ """
62
+
63
+ async def _check(request: Request) -> None:
64
+ granted = getattr(request.state, "plugin_permissions", None)
65
+ if granted is None:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
68
+ detail="plugin permission context missing",
69
+ )
70
+ if permission not in granted:
71
+ raise HTTPException(
72
+ status_code=status.HTTP_403_FORBIDDEN,
73
+ detail=f"plugin lacks permission: {permission}",
74
+ )
75
+
76
+ # The loader walks route.dependant recursively and refuses to mount any
77
+ # route without at least one dep whose callable has this attribute set.
78
+ setattr(_check, _PALETTE_PERMISSION_ATTR, permission)
79
+ return Depends(_check)
80
+
81
+
82
+ def is_permission_dep(callable_: object) -> str | None:
83
+ """Return the permission string if `callable_` was produced by
84
+ `require_permission(...)`, otherwise None. Used by the platform loader.
85
+ """
86
+ return getattr(callable_, _PALETTE_PERMISSION_ATTR, None)
87
+
88
+
89
+ __all__ = [
90
+ "KNOWN_PERMISSIONS",
91
+ "is_known_permission",
92
+ "require_permission",
93
+ "is_permission_dep",
94
+ ]
@@ -0,0 +1,63 @@
1
+ """Plugin execution context — injected by the platform runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from fastapi import Depends, Request
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+
12
+ @dataclass
13
+ class PluginContext:
14
+ """Context provided to plugin endpoints by the platform.
15
+
16
+ Attributes:
17
+ db: Async SQLAlchemy session. If the plugin declares a `database` block
18
+ in its manifest, this is a plugin-scoped session: `search_path` is
19
+ set to the plugin's schema and the `app.current_org_id` session
20
+ variable is applied, so row-level security filters queries to the
21
+ caller's organization automatically. Do not add `WHERE
22
+ organization_id = ?` — RLS already does it.
23
+ user_id: UUID of the authenticated user
24
+ organization_id: Current org ID
25
+ org_role: User's role in the org (owner/admin/member)
26
+ plugin_id: The plugin's ID from its manifest
27
+ permissions: List of permissions declared in the manifest
28
+ storage: Storage service for file upload/download
29
+ """
30
+ db: AsyncSession
31
+ user_id: str
32
+ organization_id: int
33
+ org_role: str | None = None
34
+ plugin_id: str = ""
35
+ permissions: list[str] = field(default_factory=list)
36
+ storage: Any = None # Platform storage service injected at runtime
37
+ config: dict[str, Any] = field(default_factory=dict)
38
+
39
+
40
+ async def get_plugin_context(request: Request) -> PluginContext:
41
+ """FastAPI dependency that extracts plugin context from the request.
42
+
43
+ The platform's plugin loader injects the necessary state into
44
+ request.state before the plugin endpoint is called.
45
+
46
+ Usage:
47
+ @router.get("/data")
48
+ async def get_data(ctx: PluginContext = Depends(get_plugin_context)):
49
+ result = await ctx.db.execute(...)
50
+ return result
51
+ """
52
+ state = request.state
53
+
54
+ return PluginContext(
55
+ db=state.db,
56
+ user_id=str(state.user.id),
57
+ organization_id=state.user.organization_id,
58
+ org_role=getattr(state, "org_role", None),
59
+ plugin_id=getattr(state, "plugin_id", ""),
60
+ permissions=getattr(state, "plugin_permissions", []),
61
+ storage=getattr(state, "storage", None),
62
+ config=getattr(state, "plugin_config", {}),
63
+ )
@@ -0,0 +1,27 @@
1
+ """Plugin-scoped FastAPI router with automatic prefix and auth."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+
6
+ class PluginRouter(APIRouter):
7
+ """A FastAPI router scoped to a plugin.
8
+
9
+ Routes defined on this router will be automatically mounted
10
+ under /api/v1/plugins/{plugin_id}/ by the platform loader.
11
+
12
+ Usage:
13
+ from palette_sdk import PluginRouter
14
+
15
+ router = PluginRouter(tags=["my-plugin"])
16
+
17
+ @router.get("/stats")
18
+ async def get_stats(ctx = Depends(get_plugin_context)):
19
+ # This will be available at /api/v1/plugins/my-plugin/stats
20
+ ...
21
+ """
22
+
23
+ def __init__(self, **kwargs):
24
+ # Inherit FastAPI's default response class (JSONResponse). The
25
+ # previous code set this to None, which broke both the response
26
+ # pipeline (TypeError at serialization) and OpenAPI generation.
27
+ super().__init__(**kwargs)
@@ -0,0 +1,30 @@
1
+ """Common response schemas for plugin APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ from pydantic import BaseModel
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class SuccessResponse(BaseModel):
13
+ """Generic success response."""
14
+ success: bool = True
15
+ message: str = "OK"
16
+ data: Any = None
17
+
18
+
19
+ class ErrorResponse(BaseModel):
20
+ """Generic error response."""
21
+ success: bool = False
22
+ detail: str
23
+
24
+
25
+ class PaginatedResponse(BaseModel, Generic[T]):
26
+ """Paginated list response."""
27
+ items: list[Any]
28
+ total: int
29
+ page: int = 1
30
+ page_size: int = 50
@@ -0,0 +1,45 @@
1
+ """Testing helpers for Palette backend plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+
7
+
8
+ def route_permission_issues(router: APIRouter, public_routes: list[str] | None = None) -> list[str]:
9
+ """Return route paths that lack `require_permission`.
10
+
11
+ This mirrors the platform loader's mount-time check and lets plugin tests
12
+ fail before publish.
13
+ """
14
+ from palette_sdk.permissions import is_permission_dep
15
+
16
+ allowed = set(public_routes or [])
17
+ issues: list[str] = []
18
+
19
+ for route in router.routes:
20
+ path = getattr(route, "path", None)
21
+ if path is None or path in allowed:
22
+ continue
23
+ dependant = getattr(route, "dependant", None)
24
+ if dependant is None:
25
+ continue
26
+
27
+ stack = [dependant]
28
+ seen: set[int] = set()
29
+ gated = False
30
+ while stack:
31
+ dep = stack.pop()
32
+ if id(dep) in seen:
33
+ continue
34
+ seen.add(id(dep))
35
+ call = getattr(dep, "call", None)
36
+ if call is not None and is_permission_dep(call):
37
+ gated = True
38
+ break
39
+ stack.extend(getattr(dep, "dependencies", []) or [])
40
+
41
+ if not gated:
42
+ methods = ",".join(sorted(getattr(route, "methods", []) or [])) or "?"
43
+ issues.append(f"{methods} {path}")
44
+
45
+ return issues
@@ -0,0 +1,66 @@
1
+ """Base class for defining custom agent tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+
9
+ class ToolDefinition(ABC):
10
+ """Base class for custom agent tools provided by plugins.
11
+
12
+ Subclass this to create tools that agents can use in chat conversations.
13
+
14
+ Example:
15
+ class ChartTool(ToolDefinition):
16
+ name = "generate_chart"
17
+ description = "Generate a chart from data"
18
+ input_schema = {
19
+ "type": "object",
20
+ "properties": {
21
+ "chart_type": {"type": "string", "enum": ["bar", "line", "pie"]},
22
+ "data": {"type": "array", "items": {"type": "object"}},
23
+ "title": {"type": "string"},
24
+ },
25
+ "required": ["chart_type", "data"],
26
+ }
27
+
28
+ async def run(self, input_data: dict, context: dict) -> str:
29
+ # Generate chart and return result
30
+ return "Chart generated: https://..."
31
+ """
32
+
33
+ name: str = ""
34
+ description: str = ""
35
+ input_schema: dict[str, Any] = {}
36
+
37
+ @abstractmethod
38
+ async def run(self, input_data: dict[str, Any], context: dict[str, Any]) -> str:
39
+ """Execute the tool with the given input.
40
+
41
+ Args:
42
+ input_data: Validated input matching the input_schema
43
+ context: Runtime context with user_id, org_id, db session, etc.
44
+
45
+ Returns:
46
+ String result to be sent back to the agent
47
+ """
48
+ ...
49
+
50
+ def to_langchain_tool(self):
51
+ """Convert to a LangChain-compatible tool function.
52
+
53
+ Called by the platform when building an agent's tool list.
54
+ Plugin developers don't need to call this directly.
55
+ """
56
+ from langchain_core.tools import StructuredTool
57
+
58
+ async def _run(**kwargs):
59
+ return await self.run(kwargs, {})
60
+
61
+ return StructuredTool.from_function(
62
+ coroutine=_run,
63
+ name=self.name,
64
+ description=self.description,
65
+ args_schema=None,
66
+ )
@@ -0,0 +1,17 @@
1
+ """Small webhook utilities for plugin authors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ from hashlib import sha256
7
+
8
+
9
+ def sign_webhook(secret: str, payload: bytes) -> str:
10
+ """Return a hex HMAC-SHA256 signature for `payload`."""
11
+ return hmac.new(secret.encode(), payload, sha256).hexdigest()
12
+
13
+
14
+ def verify_webhook_signature(secret: str, payload: bytes, signature: str) -> bool:
15
+ """Constant-time HMAC-SHA256 verification."""
16
+ expected = sign_webhook(secret, payload)
17
+ return hmac.compare_digest(expected, signature)
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "palette-sdk"
3
+ version = "0.1.3"
4
+ description = "Palette Platform SDK for building backend plugins"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [{ name = "Palette Platform" }]
9
+ keywords = ["palette", "plugin", "sdk", "fastapi"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Framework :: FastAPI",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ ]
16
+ dependencies = [
17
+ "fastapi>=0.129.0",
18
+ "pydantic>=2.12.0",
19
+ "sqlalchemy>=2.0.47",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/palette-lab/palette-virtual-organization-backend/releases"
24
+ Repository = "https://github.com/palette-lab/palette-virtual-organization-backend"
25
+ Documentation = "https://github.com/palette-lab/palette-virtual-organization-backend/releases"
26
+
27
+ [build-system]
28
+ requires = ["setuptools>=75.0"]
29
+ build-backend = "setuptools.build_meta"
30
+
31
+ [tool.setuptools.packages.find]
32
+ include = ["palette_sdk*"]
@@ -36,8 +36,11 @@ function reporter(json, results) {
36
36
  }
37
37
 
38
38
  function localBackendSdkPath() {
39
- const candidate = path.resolve(__dirname, "..", "..", "..", "backend")
40
- return fs.existsSync(path.join(candidate, "palette_sdk")) ? candidate : null
39
+ const candidates = [
40
+ path.resolve(__dirname, "..", "..", "..", "backend"),
41
+ path.resolve(__dirname, "..", "..", "backend-sdk"),
42
+ ]
43
+ return candidates.find((candidate) => fs.existsSync(path.join(candidate, "palette_sdk"))) || null
41
44
  }
42
45
 
43
46
  function localBackendSdkPython() {
@@ -432,7 +435,8 @@ function backendSdkVersionFromDependency(cwd) {
432
435
  const constrained = String(dep).match(/^palette-sdk\s*(?:[<>=~!]=?\s*)?(\d+\.\d+\.\d+)/)
433
436
  if (constrained) return constrained[1]
434
437
  }
435
- const localPyproject = path.resolve(__dirname, "..", "..", "..", "backend", "pyproject.toml")
438
+ const sdkPath = localBackendSdkPath()
439
+ const localPyproject = sdkPath ? path.join(sdkPath, "pyproject.toml") : ""
436
440
  if (fs.existsSync(localPyproject)) {
437
441
  const match = fs.readFileSync(localPyproject, "utf8").match(/^version\s*=\s*["']([^"']+)["']/m)
438
442
  if (match) return match[1]
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
7
7
  },
8
8
  "files": [
9
9
  "bin",
10
+ "backend-sdk",
10
11
  "lib",
11
12
  "platform-dev",
12
13
  "template-fallback",
14
+ "!backend-sdk/**/__pycache__",
15
+ "!backend-sdk/**/*.pyc",
13
16
  "!template-fallback/**/__pycache__",
14
17
  "!template-fallback/**/*.pyc",
15
18
  "palette.config.example.json",
@@ -14,5 +14,6 @@ requires-python = ">=3.12"
14
14
  dependencies = [
15
15
  "fastapi>=0.129.0",
16
16
  "sqlalchemy>=2.0.47",
17
- "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-v0.1.0/palette_sdk-0.1.0-py3-none-any.whl",
17
+ # `pltt test` ships the backend SDK on PYTHONPATH. Pin a released
18
+ # palette-sdk package here only if you run backend tests outside the CLI.
18
19
  ]
@@ -5,5 +5,6 @@ requires-python = ">=3.12"
5
5
  dependencies = [
6
6
  "fastapi>=0.129.0",
7
7
  "pydantic>=2.12.0",
8
- "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-v0.1.0/palette_sdk-0.1.0-py3-none-any.whl",
8
+ "sqlalchemy>=2.0.47",
9
+ # `pltt test` ships the backend SDK on PYTHONPATH.
9
10
  ]
@@ -4,5 +4,6 @@ version = "1.0.0"
4
4
  requires-python = ">=3.12"
5
5
  dependencies = [
6
6
  "fastapi>=0.129.0",
7
- "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-v0.1.0/palette_sdk-0.1.0-py3-none-any.whl",
7
+ "sqlalchemy>=2.0.47",
8
+ # `pltt test` ships the backend SDK on PYTHONPATH.
8
9
  ]
@@ -6,5 +6,5 @@ dependencies = [
6
6
  "fastapi>=0.129.0",
7
7
  "sqlalchemy>=2.0.47",
8
8
  "alembic>=1.17.0",
9
- "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-v0.1.0/palette_sdk-0.1.0-py3-none-any.whl",
9
+ # `pltt test` ships the backend SDK on PYTHONPATH.
10
10
  ]
@@ -5,5 +5,6 @@ requires-python = ">=3.12"
5
5
  dependencies = [
6
6
  "fastapi>=0.129.0",
7
7
  "httpx>=0.28.0",
8
- "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-v0.1.0/palette_sdk-0.1.0-py3-none-any.whl",
8
+ "sqlalchemy>=2.0.47",
9
+ # `pltt test` ships the backend SDK on PYTHONPATH.
9
10
  ]