@palettelab/cli 0.3.42 → 0.3.43
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.
- package/README.md +3 -2
- package/backend-sdk/palette_sdk/__init__.py +10 -1
- package/backend-sdk/palette_sdk/apps.py +66 -0
- package/backend-sdk/palette_sdk/connections.py +83 -0
- package/backend-sdk/palette_sdk/events.py +12 -0
- package/backend-sdk/palette_sdk/manifest.py +54 -0
- package/backend-sdk/palette_sdk/plugin_context.py +12 -0
- package/docs/python-backend-sdk.md +87 -1
- package/lib/commands/test.js +13 -2
- package/lib/dev-simulator.js +131 -2
- package/lib/manifest.js +205 -0
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/palette-plugin.json +1 -0
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/external-service/README.md +4 -0
- package/template-fallback/templates/external-service/backend/api/main.py +2 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/external-service/palette-plugin.json +11 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/palette-app/package.json +1 -1
package/README.md
CHANGED
|
@@ -276,11 +276,12 @@ async def create_invoice(body: InvoiceIn, ctx: PluginContext = Depends(get_plugi
|
|
|
276
276
|
Backend SDK features for app-owned data:
|
|
277
277
|
|
|
278
278
|
- `PluginRouter`, `PluginContext`, and `get_plugin_context` provide the FastAPI route and request-context surface.
|
|
279
|
-
- `PluginContext` exposes `user_id`, `organization_id`, `org_role`, `plugin_id`, `permissions`, `storage`, `ctx.db`, `ctx.data_rooms`, `ctx.members`, `ctx.redis`, `ctx.vector`, `ctx.config`, and `ctx.logger`.
|
|
279
|
+
- `PluginContext` exposes `user_id`, `organization_id`, `org_role`, `plugin_id`, `permissions`, `storage`, `ctx.db`, `ctx.data_rooms`, `ctx.connections`, `ctx.members`, `ctx.redis`, `ctx.vector`, `ctx.config`, and `ctx.logger`.
|
|
280
280
|
- `ctx.db` is the full scoped SQLAlchemy `AsyncSession` for app-owned database data.
|
|
281
281
|
- `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
|
|
282
282
|
- `ctx.data_rooms` gives backend access to Palette Data Rooms without importing platform internals.
|
|
283
283
|
- `ctx.members` gives backend access to current organisation members; it exposes list/get/invite/update-role helpers, but no delete/remove helper.
|
|
284
|
+
- `ctx.connections.status(id)`, `ctx.connections.require(id)`, and `ctx.connections.access_token(id)` read Palette-managed third-party connections declared in `palette-plugin.json`.
|
|
284
285
|
- `ctx.has_permission("...")`, `ctx.has_any_permission([...])`, and `ctx.has_all_permissions([...])` check declared permissions.
|
|
285
286
|
- `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
|
|
286
287
|
- `ctx.secret("KEY")` reads app secrets from config or environment variables.
|
|
@@ -663,7 +664,7 @@ pltt test
|
|
|
663
664
|
pltt test --json
|
|
664
665
|
```
|
|
665
666
|
|
|
666
|
-
Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`. The default frontend bundle
|
|
667
|
+
Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`. The default frontend and backend bundle limits are 15 MiB; set `PALETTE_MAX_FRONTEND_BUNDLE_BYTES` or `PALETTE_MAX_BACKEND_BUNDLE_BYTES` for reviewed exceptions.
|
|
667
668
|
|
|
668
669
|
### `pltt package`
|
|
669
670
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from palette_sdk.plugin_router import PluginRouter
|
|
4
4
|
from palette_sdk.plugin_context import MissingSecretError, PluginContext, get_plugin_context
|
|
5
5
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
6
|
+
from palette_sdk.connections import ConnectionStatus, MissingConnectionError, PluginConnectionsClient
|
|
7
|
+
from palette_sdk.apps import AppInteropClient, AppServiceClient, MissingAppServiceError
|
|
6
8
|
from palette_sdk.members import OrganizationMembersClient
|
|
7
9
|
from palette_sdk.platform_services import (
|
|
8
10
|
LocalRedisService,
|
|
@@ -32,7 +34,7 @@ from palette_sdk.permissions import (
|
|
|
32
34
|
is_known_permission,
|
|
33
35
|
require_permission,
|
|
34
36
|
)
|
|
35
|
-
from palette_sdk.events import Event, subscribe_event
|
|
37
|
+
from palette_sdk.events import Event, EventPublisher, subscribe_event
|
|
36
38
|
from palette_sdk.config import get_config, require_config
|
|
37
39
|
from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
|
|
38
40
|
from palette_sdk.testing import route_permission_issues
|
|
@@ -44,6 +46,12 @@ __all__ = [
|
|
|
44
46
|
"MissingSecretError",
|
|
45
47
|
"get_plugin_context",
|
|
46
48
|
"DataRoomsClient",
|
|
49
|
+
"ConnectionStatus",
|
|
50
|
+
"MissingConnectionError",
|
|
51
|
+
"PluginConnectionsClient",
|
|
52
|
+
"AppInteropClient",
|
|
53
|
+
"AppServiceClient",
|
|
54
|
+
"MissingAppServiceError",
|
|
47
55
|
"OrganizationMembersClient",
|
|
48
56
|
"LocalRedisService",
|
|
49
57
|
"LocalVectorService",
|
|
@@ -67,6 +75,7 @@ __all__ = [
|
|
|
67
75
|
"is_known_permission",
|
|
68
76
|
"require_permission",
|
|
69
77
|
"Event",
|
|
78
|
+
"EventPublisher",
|
|
70
79
|
"subscribe_event",
|
|
71
80
|
"get_config",
|
|
72
81
|
"require_config",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""App-to-app communication helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MissingAppServiceError(RuntimeError):
|
|
9
|
+
"""Raised when the platform did not inject app-to-app helpers."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppServiceClient:
|
|
13
|
+
def __init__(self, adapter: Any, service_id: str):
|
|
14
|
+
self._adapter = adapter
|
|
15
|
+
self.service_id = service_id
|
|
16
|
+
|
|
17
|
+
async def call(
|
|
18
|
+
self,
|
|
19
|
+
path: str,
|
|
20
|
+
*,
|
|
21
|
+
method: str = "GET",
|
|
22
|
+
json: Any = None,
|
|
23
|
+
timeout: float = 30.0,
|
|
24
|
+
) -> Any:
|
|
25
|
+
if self._adapter is None:
|
|
26
|
+
raise MissingAppServiceError("Palette app service helpers are not available in this runtime")
|
|
27
|
+
return await self._adapter.call_service(
|
|
28
|
+
self.service_id,
|
|
29
|
+
path,
|
|
30
|
+
method=method,
|
|
31
|
+
json_body=json,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def get(self, path: str, **kwargs: Any) -> Any:
|
|
36
|
+
return await self.call(path, method="GET", **kwargs)
|
|
37
|
+
|
|
38
|
+
async def post(self, path: str, json: Any = None, **kwargs: Any) -> Any:
|
|
39
|
+
return await self.call(path, method="POST", json=json, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AppInteropClient:
|
|
43
|
+
def __init__(self, adapter: Any = None):
|
|
44
|
+
self._adapter = adapter
|
|
45
|
+
|
|
46
|
+
def service(self, service_id: str) -> AppServiceClient:
|
|
47
|
+
return AppServiceClient(self._adapter, service_id)
|
|
48
|
+
|
|
49
|
+
async def call(
|
|
50
|
+
self,
|
|
51
|
+
app_id: str,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
method: str = "GET",
|
|
55
|
+
json: Any = None,
|
|
56
|
+
timeout: float = 30.0,
|
|
57
|
+
) -> Any:
|
|
58
|
+
if self._adapter is None:
|
|
59
|
+
raise MissingAppServiceError("Palette app service helpers are not available in this runtime")
|
|
60
|
+
return await self._adapter.call(
|
|
61
|
+
app_id,
|
|
62
|
+
path,
|
|
63
|
+
method=method,
|
|
64
|
+
json_body=json,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Palette-managed third-party connection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissingConnectionError(RuntimeError):
|
|
11
|
+
"""Raised when plugin code requires a connection that is not connected."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConnectionStatus:
|
|
16
|
+
id: str
|
|
17
|
+
provider: str
|
|
18
|
+
label: str
|
|
19
|
+
auth: str = "oauth2"
|
|
20
|
+
scopes: list[str] = field(default_factory=list)
|
|
21
|
+
required: bool = False
|
|
22
|
+
status: str = "available"
|
|
23
|
+
account_label: str | None = None
|
|
24
|
+
granted_scopes: list[str] = field(default_factory=list)
|
|
25
|
+
expires_at: str | None = None
|
|
26
|
+
connected_at: str | None = None
|
|
27
|
+
last_error: str | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def connected(self) -> bool:
|
|
31
|
+
return self.status in {"connected", "expiring"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _to_status(raw: dict[str, Any]) -> ConnectionStatus:
|
|
35
|
+
return ConnectionStatus(
|
|
36
|
+
id=str(raw.get("id") or raw.get("connection_id") or ""),
|
|
37
|
+
provider=str(raw.get("provider") or "custom"),
|
|
38
|
+
label=str(raw.get("label") or raw.get("id") or raw.get("connection_id") or ""),
|
|
39
|
+
auth=str(raw.get("auth") or "oauth2"),
|
|
40
|
+
scopes=list(raw.get("scopes") or []),
|
|
41
|
+
required=bool(raw.get("required") or False),
|
|
42
|
+
status=str(raw.get("status") or "available"),
|
|
43
|
+
account_label=raw.get("account_label"),
|
|
44
|
+
granted_scopes=list(raw.get("granted_scopes") or []),
|
|
45
|
+
expires_at=raw.get("expires_at"),
|
|
46
|
+
connected_at=raw.get("connected_at"),
|
|
47
|
+
last_error=raw.get("last_error"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PluginConnectionsClient:
|
|
52
|
+
"""Access install-scoped third-party connections for a plugin."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, adapter: Any | None = None, local_connections: dict[str, Any] | None = None):
|
|
55
|
+
self._adapter = adapter
|
|
56
|
+
self._local_connections = local_connections or {}
|
|
57
|
+
|
|
58
|
+
async def list(self) -> list[ConnectionStatus]:
|
|
59
|
+
if self._adapter is not None and hasattr(self._adapter, "list"):
|
|
60
|
+
return [_to_status(item) for item in await self._adapter.list()]
|
|
61
|
+
return [_to_status(item) for item in self._local_connections.values()]
|
|
62
|
+
|
|
63
|
+
async def status(self, connection_id: str) -> ConnectionStatus:
|
|
64
|
+
if self._adapter is not None and hasattr(self._adapter, "status"):
|
|
65
|
+
return _to_status(await self._adapter.status(connection_id))
|
|
66
|
+
raw = self._local_connections.get(connection_id) or {"id": connection_id, "status": "available"}
|
|
67
|
+
return _to_status(raw)
|
|
68
|
+
|
|
69
|
+
async def require(self, connection_id: str) -> ConnectionStatus:
|
|
70
|
+
status = await self.status(connection_id)
|
|
71
|
+
if not status.connected:
|
|
72
|
+
raise MissingConnectionError(f"Palette connection is not configured: {connection_id}")
|
|
73
|
+
return status
|
|
74
|
+
|
|
75
|
+
async def access_token(self, connection_id: str) -> str:
|
|
76
|
+
if self._adapter is not None and hasattr(self._adapter, "access_token"):
|
|
77
|
+
token = await self._adapter.access_token(connection_id)
|
|
78
|
+
if token:
|
|
79
|
+
return str(token)
|
|
80
|
+
status = await self.require(connection_id)
|
|
81
|
+
if status.connected:
|
|
82
|
+
return f"palette-local-token:{connection_id}:{int(datetime.now(timezone.utc).timestamp())}"
|
|
83
|
+
raise MissingConnectionError(f"Palette connection is not configured: {connection_id}")
|
|
@@ -52,3 +52,15 @@ def drain_pending() -> list[tuple[str, Handler]]:
|
|
|
52
52
|
out = list(_pending)
|
|
53
53
|
_pending.clear()
|
|
54
54
|
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EventPublisher:
|
|
58
|
+
"""Publish app-owned events declared in palette-plugin.json provides.events."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, adapter: Any = None):
|
|
61
|
+
self._adapter = adapter
|
|
62
|
+
|
|
63
|
+
async def publish(self, topic: str, payload: dict[str, Any] | None = None) -> None:
|
|
64
|
+
if self._adapter is None:
|
|
65
|
+
raise RuntimeError("Palette event publisher is not available in this runtime")
|
|
66
|
+
await self._adapter.publish(topic, payload or {})
|
|
@@ -83,6 +83,57 @@ class SecretSpec(BaseModel):
|
|
|
83
83
|
validate_pattern: str | None = Field(default=None, alias="validate")
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
class ConnectionSpec(BaseModel):
|
|
87
|
+
id: str
|
|
88
|
+
provider: Literal["google", "instagram", "slack", "linear", "hubspot", "stripe", "zendesk", "custom"]
|
|
89
|
+
label: str
|
|
90
|
+
auth: Literal["oauth2", "api_key"] = "oauth2"
|
|
91
|
+
scopes: list[str] = Field(default_factory=list)
|
|
92
|
+
required: bool = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AppServiceRouteSpec(BaseModel):
|
|
96
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
97
|
+
path: str
|
|
98
|
+
operation_id: str | None = None
|
|
99
|
+
description: str = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProvidedAppServiceSpec(BaseModel):
|
|
103
|
+
id: str
|
|
104
|
+
version: str | None = None
|
|
105
|
+
label: str | None = None
|
|
106
|
+
description: str = ""
|
|
107
|
+
permissions: list[str] = Field(default_factory=list)
|
|
108
|
+
routes: list[AppServiceRouteSpec] = Field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AppDependencySpec(BaseModel):
|
|
112
|
+
id: str
|
|
113
|
+
version: str | None = None
|
|
114
|
+
required: bool = True
|
|
115
|
+
reason: str = ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ServiceDependencySpec(BaseModel):
|
|
119
|
+
id: str
|
|
120
|
+
version: str | None = None
|
|
121
|
+
required: bool = True
|
|
122
|
+
reason: str = ""
|
|
123
|
+
provider: str | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ProvidesSpec(BaseModel):
|
|
127
|
+
services: list[ProvidedAppServiceSpec] = Field(default_factory=list)
|
|
128
|
+
events: list[str] = Field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RequiresSpec(BaseModel):
|
|
132
|
+
apps: list[AppDependencySpec] = Field(default_factory=list)
|
|
133
|
+
services: list[ServiceDependencySpec] = Field(default_factory=list)
|
|
134
|
+
events: list[str] = Field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
|
|
86
137
|
class PlatformServiceSpec(BaseModel):
|
|
87
138
|
required: bool = False
|
|
88
139
|
billing: Literal["org_wallet", "plugin_owner", "platform"] | None = None
|
|
@@ -113,6 +164,9 @@ class PluginManifest(BaseModel):
|
|
|
113
164
|
tools: list[ToolEntry] = Field(default_factory=list)
|
|
114
165
|
permissions: list[str] = Field(default_factory=list)
|
|
115
166
|
secrets: dict[str, SecretSpec] = Field(default_factory=dict)
|
|
167
|
+
connections: list[ConnectionSpec] = Field(default_factory=list)
|
|
168
|
+
provides: ProvidesSpec | None = None
|
|
169
|
+
requires: RequiresSpec | None = None
|
|
116
170
|
platform_services: list[Literal["llm", "redis", "storage", "vector"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
|
|
117
171
|
rating: float = 0.0
|
|
118
172
|
reviews: int = 0
|
|
@@ -11,8 +11,11 @@ from fastapi import Depends, Request
|
|
|
11
11
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
12
|
|
|
13
13
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
14
|
+
from palette_sdk.connections import PluginConnectionsClient
|
|
15
|
+
from palette_sdk.apps import AppInteropClient
|
|
14
16
|
from palette_sdk.members import OrganizationMembersClient
|
|
15
17
|
from palette_sdk.platform_services import UnavailablePlatformService
|
|
18
|
+
from palette_sdk.events import EventPublisher
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
class MissingSecretError(KeyError):
|
|
@@ -46,9 +49,12 @@ class PluginContext:
|
|
|
46
49
|
permissions: list[str] = field(default_factory=list)
|
|
47
50
|
storage: Any = None # Platform storage service injected at runtime
|
|
48
51
|
data_rooms: DataRoomsClient = field(default_factory=DataRoomsClient)
|
|
52
|
+
connections: PluginConnectionsClient = field(default_factory=PluginConnectionsClient)
|
|
53
|
+
apps: AppInteropClient = field(default_factory=AppInteropClient)
|
|
49
54
|
members: OrganizationMembersClient = field(default_factory=OrganizationMembersClient)
|
|
50
55
|
redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
|
|
51
56
|
vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
|
|
57
|
+
events: EventPublisher = field(default_factory=EventPublisher)
|
|
52
58
|
config: dict[str, Any] = field(default_factory=dict)
|
|
53
59
|
logger: logging.Logger = field(default_factory=lambda: logging.getLogger("palette_sdk.plugin"))
|
|
54
60
|
|
|
@@ -113,12 +119,18 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
113
119
|
permissions=getattr(state, "plugin_permissions", []),
|
|
114
120
|
storage=getattr(state, "storage", None) or UnavailablePlatformService("storage"),
|
|
115
121
|
data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
|
|
122
|
+
connections=PluginConnectionsClient(
|
|
123
|
+
getattr(state, "plugin_connections", None),
|
|
124
|
+
getattr(state, "plugin_local_connections", None),
|
|
125
|
+
),
|
|
126
|
+
apps=AppInteropClient(getattr(state, "plugin_apps", None)),
|
|
116
127
|
members=OrganizationMembersClient(
|
|
117
128
|
getattr(state, "org_members", None),
|
|
118
129
|
getattr(state, "plugin_permissions", []),
|
|
119
130
|
),
|
|
120
131
|
redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
|
|
121
132
|
vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
|
|
133
|
+
events=EventPublisher(getattr(state, "plugin_events", None)),
|
|
122
134
|
config=getattr(state, "plugin_config", {}),
|
|
123
135
|
logger=getattr(state, "plugin_logger", logging.getLogger(f"palette_sdk.plugin.{getattr(state, 'plugin_id', 'unknown')}")),
|
|
124
136
|
)
|
|
@@ -112,7 +112,10 @@ Available context values:
|
|
|
112
112
|
| `ctx.permissions` | Permissions granted from `palette-plugin.json` |
|
|
113
113
|
| `ctx.storage` | Runtime storage service, when available |
|
|
114
114
|
| `ctx.data_rooms` | Backend Data Room client |
|
|
115
|
+
| `ctx.connections` | Palette-managed third-party connection client |
|
|
115
116
|
| `ctx.members` | Current organisation member client |
|
|
117
|
+
| `ctx.apps` | Governed app-to-app calls through declared `requires` contracts |
|
|
118
|
+
| `ctx.events` | Publish event topics declared in `provides.events` |
|
|
116
119
|
| `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
|
|
117
120
|
| `ctx.vector` | Plugin/org-scoped vector service when `platform_services` includes `vector` |
|
|
118
121
|
| `ctx.config` | App install/config values |
|
|
@@ -138,6 +141,7 @@ These are the public Python helpers exported by `palette_sdk`.
|
|
|
138
141
|
| `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
|
|
139
142
|
| `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
|
|
140
143
|
| `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
|
|
144
|
+
| `AppInteropClient`, `ctx.apps` | Call required apps/services without direct database access |
|
|
141
145
|
| `OrgRepository`, `ctx.repo(Model)` | Org-safe convenience CRUD for app-owned models |
|
|
142
146
|
| `PluginBase`, `OrgScopedTable` | SQLAlchemy declarative bases for plugin-owned tables |
|
|
143
147
|
| `ensure_org_rls(op, table)` | Alembic helper that enables org row-level security |
|
|
@@ -146,7 +150,7 @@ These are the public Python helpers exported by `palette_sdk`.
|
|
|
146
150
|
| `LocalRedisService`, `LocalVectorService` | Local `pltt dev` service emulators and test fakes |
|
|
147
151
|
| `PlatformServiceUnavailable`, `UnavailablePlatformService` | Clear errors when an undeclared platform service is used |
|
|
148
152
|
| `LifecycleHooks` | Install/update/enable/disable/uninstall callbacks |
|
|
149
|
-
| `Event`, `subscribe_event(...)` | In-process
|
|
153
|
+
| `Event`, `EventPublisher`, `subscribe_event(...)` | In-process event subscriptions and declared app event publishing |
|
|
150
154
|
| `sign_webhook(...)`, `verify_webhook_signature(...)` | HMAC-SHA256 webhook signing and verification |
|
|
151
155
|
| `ToolDefinition` | Base class for custom agent tools |
|
|
152
156
|
| `PluginManifest`, `load_manifest(...)` | Typed manifest parsing from `palette-plugin.json` |
|
|
@@ -654,6 +658,88 @@ Palette scopes every Redis key and vector operation by `plugin_id` and
|
|
|
654
658
|
`organization_id`; hosted previews also include the publish id. Plugin code
|
|
655
659
|
cannot read, list, update, or delete records owned by another app or org.
|
|
656
660
|
|
|
661
|
+
## 10. Managed Connections
|
|
662
|
+
|
|
663
|
+
Declare third-party OAuth or API-key connections in `palette-plugin.json`.
|
|
664
|
+
Palette renders them in the installed app's Settings > Connect tab and stores
|
|
665
|
+
connected token state encrypted per organisation install.
|
|
666
|
+
|
|
667
|
+
```json
|
|
668
|
+
{
|
|
669
|
+
"connections": [
|
|
670
|
+
{
|
|
671
|
+
"id": "google_calendar",
|
|
672
|
+
"provider": "google",
|
|
673
|
+
"label": "Google Calendar",
|
|
674
|
+
"auth": "oauth2",
|
|
675
|
+
"scopes": ["https://www.googleapis.com/auth/calendar.readonly"]
|
|
676
|
+
}
|
|
677
|
+
]
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
```python
|
|
682
|
+
status = await ctx.connections.status("google_calendar")
|
|
683
|
+
connected = await ctx.connections.require("google_calendar")
|
|
684
|
+
token = await ctx.connections.access_token("google_calendar")
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
`pltt dev` mocks declared connections. Override local states with
|
|
688
|
+
`.palette/connections.local.json`.
|
|
689
|
+
|
|
690
|
+
## 11. App-To-App Services
|
|
691
|
+
|
|
692
|
+
Apps can expose governed services and depend on services from other installed
|
|
693
|
+
apps. Declare provider capabilities with `provides`:
|
|
694
|
+
|
|
695
|
+
```json
|
|
696
|
+
{
|
|
697
|
+
"provides": {
|
|
698
|
+
"services": [
|
|
699
|
+
{
|
|
700
|
+
"id": "org.hierarchy",
|
|
701
|
+
"version": "1.0.0",
|
|
702
|
+
"routes": [
|
|
703
|
+
{ "method": "GET", "path": "/hierarchy/approval-chain/{user_id}" }
|
|
704
|
+
]
|
|
705
|
+
}
|
|
706
|
+
],
|
|
707
|
+
"events": ["hierarchy.updated"]
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
Consumers declare dependencies with `requires`:
|
|
713
|
+
|
|
714
|
+
```json
|
|
715
|
+
{
|
|
716
|
+
"requires": {
|
|
717
|
+
"services": [
|
|
718
|
+
{ "id": "org.hierarchy", "version": "^1.0.0", "reason": "Approval routing" }
|
|
719
|
+
]
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Then call through `ctx.apps`; never read another app's database directly. If
|
|
725
|
+
the same app emits `leave.requested`, declare that topic under its own
|
|
726
|
+
`provides.events` first:
|
|
727
|
+
|
|
728
|
+
```python
|
|
729
|
+
chain = await ctx.apps.service("org.hierarchy").get(f"/hierarchy/approval-chain/{ctx.user_id}")
|
|
730
|
+
await ctx.events.publish("leave.requested", {"next_approver_id": chain["next_approver_id"]})
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Local mocks live in `.palette/app-services.local.json`:
|
|
734
|
+
|
|
735
|
+
```json
|
|
736
|
+
{
|
|
737
|
+
"org.hierarchy GET /hierarchy/approval-chain/dev-user": {
|
|
738
|
+
"next_approver_id": "manager-1"
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
657
743
|
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
658
744
|
frontend storage client live under:
|
|
659
745
|
|
package/lib/commands/test.js
CHANGED
|
@@ -8,8 +8,8 @@ const { bundleFrontend, bundleBackend } = require("../bundler")
|
|
|
8
8
|
const { declaredSecrets, loadLocalEnv } = require("../secrets")
|
|
9
9
|
const buildCommand = require("./build")
|
|
10
10
|
|
|
11
|
-
const DEFAULT_FRONTEND_BUNDLE_LIMIT =
|
|
12
|
-
const DEFAULT_BACKEND_BUNDLE_LIMIT =
|
|
11
|
+
const DEFAULT_FRONTEND_BUNDLE_LIMIT = 15 * 1024 * 1024
|
|
12
|
+
const DEFAULT_BACKEND_BUNDLE_LIMIT = 15 * 1024 * 1024
|
|
13
13
|
|
|
14
14
|
function reporter(json, results) {
|
|
15
15
|
return {
|
|
@@ -705,6 +705,17 @@ async function run(args, { cwd }) {
|
|
|
705
705
|
}
|
|
706
706
|
if ((manifest.permissions || []).length) out.ok("declared permissions are known")
|
|
707
707
|
|
|
708
|
+
if (manifest.provides || manifest.requires) {
|
|
709
|
+
const providedServices = manifest.provides?.services?.map((item) => item.id).filter(Boolean) || []
|
|
710
|
+
const requiredServices = manifest.requires?.services?.map((item) => item.id).filter(Boolean) || []
|
|
711
|
+
const requiredApps = manifest.requires?.apps?.map((item) => item.id).filter(Boolean) || []
|
|
712
|
+
out.ok("app-to-app contracts are valid", {
|
|
713
|
+
provides_services: providedServices,
|
|
714
|
+
requires_services: requiredServices,
|
|
715
|
+
requires_apps: requiredApps,
|
|
716
|
+
})
|
|
717
|
+
}
|
|
718
|
+
|
|
708
719
|
if (manifest.frontend?.entry && manifest.frontend.sandbox === false) {
|
|
709
720
|
failures += out.fail(
|
|
710
721
|
"appstore frontend must be sandboxed",
|
package/lib/dev-simulator.js
CHANGED
|
@@ -51,6 +51,58 @@ function needsDatabase(manifest) {
|
|
|
51
51
|
return Boolean(manifest.database || manifest.capabilities?.database)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function loadLocalConnections(cwd, manifest) {
|
|
55
|
+
const declared = Array.isArray(manifest.connections) ? manifest.connections : []
|
|
56
|
+
const out = {}
|
|
57
|
+
for (const conn of declared) {
|
|
58
|
+
if (!conn || !conn.id) continue
|
|
59
|
+
out[conn.id] = {
|
|
60
|
+
id: conn.id,
|
|
61
|
+
provider: conn.provider || "custom",
|
|
62
|
+
label: conn.label || conn.id,
|
|
63
|
+
auth: conn.auth || "oauth2",
|
|
64
|
+
scopes: conn.scopes || [],
|
|
65
|
+
required: Boolean(conn.required),
|
|
66
|
+
status: "available",
|
|
67
|
+
account_label: null,
|
|
68
|
+
granted_scopes: [],
|
|
69
|
+
expires_at: null,
|
|
70
|
+
connected_at: null,
|
|
71
|
+
last_error: null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const localPath = path.join(cwd, ".palette", "connections.local.json")
|
|
75
|
+
if (!fs.existsSync(localPath)) return out
|
|
76
|
+
try {
|
|
77
|
+
const raw = JSON.parse(fs.readFileSync(localPath, "utf8"))
|
|
78
|
+
const overrides = Array.isArray(raw) ? raw : Object.entries(raw).map(([id, value]) => ({ id, ...(value || {}) }))
|
|
79
|
+
for (const item of overrides) {
|
|
80
|
+
if (!item || !item.id || !out[item.id]) continue
|
|
81
|
+
out[item.id] = {
|
|
82
|
+
...out[item.id],
|
|
83
|
+
...item,
|
|
84
|
+
id: item.id,
|
|
85
|
+
granted_scopes: item.granted_scopes || item.scopes || out[item.id].scopes || [],
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.warn(`[pltt] could not parse .palette/connections.local.json: ${err.message}`)
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadLocalAppServiceMocks(cwd) {
|
|
95
|
+
const mockPath = path.join(cwd, ".palette", "app-services.local.json")
|
|
96
|
+
if (!fs.existsSync(mockPath)) return {}
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(fs.readFileSync(mockPath, "utf8"))
|
|
99
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn(`[pltt] could not parse .palette/app-services.local.json: ${err.message}`)
|
|
102
|
+
return {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
54
106
|
function ensurePythonEnv(cwd, devDir, manifest) {
|
|
55
107
|
const hostPython = process.env.PALETTE_PYTHON || "python3"
|
|
56
108
|
const venvDir = path.join(devDir, "backend-venv")
|
|
@@ -98,6 +150,8 @@ function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
|
|
|
98
150
|
const sdkPath = localBackendSdkPath()
|
|
99
151
|
const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
|
|
100
152
|
const devSecrets = loadLocalEnv(cwd, { apply: false })
|
|
153
|
+
const devConnections = loadLocalConnections(cwd, manifest)
|
|
154
|
+
const devAppMocks = loadLocalAppServiceMocks(cwd)
|
|
101
155
|
const content = `from __future__ import annotations
|
|
102
156
|
|
|
103
157
|
import importlib
|
|
@@ -119,6 +173,8 @@ SDK_PATH = ${JSON.stringify(sdkPath || "")}
|
|
|
119
173
|
DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities", {}).get("database"))
|
|
120
174
|
DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
|
|
121
175
|
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
176
|
+
DEV_CONNECTIONS = json.loads(${JSON.stringify(JSON.stringify(devConnections))})
|
|
177
|
+
DEV_APP_MOCKS = json.loads(${JSON.stringify(JSON.stringify(devAppMocks))})
|
|
122
178
|
|
|
123
179
|
def _service_enabled(name: str) -> bool:
|
|
124
180
|
services = MANIFEST.get("platform_services") or []
|
|
@@ -153,6 +209,39 @@ if _service_enabled("storage"):
|
|
|
153
209
|
organization_name="Palette Dev",
|
|
154
210
|
)
|
|
155
211
|
|
|
212
|
+
class LocalAppInteropService:
|
|
213
|
+
def service(self, service_id: str):
|
|
214
|
+
return LocalAppServiceClient(service_id)
|
|
215
|
+
|
|
216
|
+
async def call(self, app_id: str, path: str, *, method: str = "GET", json_body=None, timeout: float = 30.0):
|
|
217
|
+
key = app_id + " " + method.upper() + " " + path
|
|
218
|
+
if key in DEV_APP_MOCKS:
|
|
219
|
+
return DEV_APP_MOCKS[key]
|
|
220
|
+
raise RuntimeError("No local app mock configured for " + key)
|
|
221
|
+
|
|
222
|
+
async def call_service(self, service_id: str, path: str, *, method: str = "GET", json_body=None, timeout: float = 30.0):
|
|
223
|
+
key = service_id + " " + method.upper() + " " + path
|
|
224
|
+
if key in DEV_APP_MOCKS:
|
|
225
|
+
return DEV_APP_MOCKS[key]
|
|
226
|
+
raise RuntimeError("No local app service mock configured for " + key)
|
|
227
|
+
|
|
228
|
+
class LocalAppServiceClient:
|
|
229
|
+
def __init__(self, service_id: str):
|
|
230
|
+
self.service_id = service_id
|
|
231
|
+
|
|
232
|
+
async def call(self, path: str, *, method: str = "GET", json=None, timeout: float = 30.0):
|
|
233
|
+
return await LocalAppInteropService().call_service(self.service_id, path, method=method, json_body=json, timeout=timeout)
|
|
234
|
+
|
|
235
|
+
async def get(self, path: str, **kwargs):
|
|
236
|
+
return await self.call(path, method="GET", **kwargs)
|
|
237
|
+
|
|
238
|
+
async def post(self, path: str, json=None, **kwargs):
|
|
239
|
+
return await self.call(path, method="POST", json=json, **kwargs)
|
|
240
|
+
|
|
241
|
+
class LocalEventPublisher:
|
|
242
|
+
async def publish(self, topic: str, payload=None):
|
|
243
|
+
print("[palette-event]", topic, json.dumps(payload or {}, sort_keys=True))
|
|
244
|
+
|
|
156
245
|
spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
|
|
157
246
|
module = importlib.util.module_from_spec(spec)
|
|
158
247
|
assert spec and spec.loader
|
|
@@ -190,6 +279,9 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
190
279
|
"secret_specs": MANIFEST.get("secrets") or {},
|
|
191
280
|
"secret_scope": "dev",
|
|
192
281
|
}
|
|
282
|
+
request.state.plugin_local_connections = DEV_CONNECTIONS
|
|
283
|
+
request.state.plugin_apps = LocalAppInteropService()
|
|
284
|
+
request.state.plugin_events = LocalEventPublisher()
|
|
193
285
|
request.state.storage = DEV_STORAGE
|
|
194
286
|
if DEV_REDIS is not None:
|
|
195
287
|
request.state.redis = DEV_REDIS
|
|
@@ -251,7 +343,8 @@ function startBackend(cwd, devDir, manifest, backendPort) {
|
|
|
251
343
|
return child
|
|
252
344
|
}
|
|
253
345
|
|
|
254
|
-
function simulatorEntrySource(pluginEntry, manifest, backendPort) {
|
|
346
|
+
function simulatorEntrySource(cwd, pluginEntry, manifest, backendPort) {
|
|
347
|
+
const localConnections = loadLocalConnections(cwd, manifest)
|
|
255
348
|
return `
|
|
256
349
|
import React from "react"
|
|
257
350
|
import { createRoot } from "react-dom/client"
|
|
@@ -259,9 +352,45 @@ import { PluginProvider } from "@palettelab/sdk"
|
|
|
259
352
|
import Plugin from ${JSON.stringify(pluginEntry)}
|
|
260
353
|
|
|
261
354
|
const backendBase = "http://127.0.0.1:${backendPort}"
|
|
355
|
+
const localConnections = ${JSON.stringify(localConnections)}
|
|
356
|
+
|
|
357
|
+
function connectionListResponse() {
|
|
358
|
+
return new Response(JSON.stringify(Object.values(localConnections)), {
|
|
359
|
+
status: 200,
|
|
360
|
+
headers: { "Content-Type": "application/json" },
|
|
361
|
+
})
|
|
362
|
+
}
|
|
262
363
|
|
|
263
364
|
async function apiFetch(path, init) {
|
|
264
365
|
const target = String(path || "")
|
|
366
|
+
const method = String(init?.method || "GET").toUpperCase()
|
|
367
|
+
const connectionPrefix = "/api/v1/app-installs/" + encodeURIComponent(${JSON.stringify(manifest.id)}) + "/connections"
|
|
368
|
+
if (target === connectionPrefix && method === "GET") {
|
|
369
|
+
return connectionListResponse()
|
|
370
|
+
}
|
|
371
|
+
if (target.startsWith(connectionPrefix + "/") && target.endsWith("/authorize") && method === "POST") {
|
|
372
|
+
const id = decodeURIComponent(target.slice((connectionPrefix + "/").length, -"/authorize".length))
|
|
373
|
+
if (localConnections[id]) {
|
|
374
|
+
localConnections[id] = {
|
|
375
|
+
...localConnections[id],
|
|
376
|
+
status: "connected",
|
|
377
|
+
account_label: localConnections[id].account_label || "Local mock account",
|
|
378
|
+
granted_scopes: localConnections[id].granted_scopes?.length ? localConnections[id].granted_scopes : localConnections[id].scopes,
|
|
379
|
+
connected_at: new Date().toISOString(),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return new Response(JSON.stringify({ connection_id: id, authorize_url: "palette-local://connected", mode: "mock" }), {
|
|
383
|
+
status: 200,
|
|
384
|
+
headers: { "Content-Type": "application/json" },
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
if (target.startsWith(connectionPrefix + "/") && method === "DELETE") {
|
|
388
|
+
const id = decodeURIComponent(target.slice((connectionPrefix + "/").length))
|
|
389
|
+
if (localConnections[id]) {
|
|
390
|
+
localConnections[id] = { ...localConnections[id], status: "available", account_label: null, connected_at: null, granted_scopes: [] }
|
|
391
|
+
}
|
|
392
|
+
return new Response(null, { status: 204 })
|
|
393
|
+
}
|
|
265
394
|
if (target.startsWith("http://") || target.startsWith("https://")) {
|
|
266
395
|
return fetch(target, init)
|
|
267
396
|
}
|
|
@@ -404,7 +533,7 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
|
404
533
|
if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
|
|
405
534
|
const generatedEntry = path.join(devDir, "simulator-entry.jsx")
|
|
406
535
|
const bundlePath = path.join(devDir, "simulator.js")
|
|
407
|
-
fs.writeFileSync(generatedEntry, simulatorEntrySource(absEntry, manifest, backendPort))
|
|
536
|
+
fs.writeFileSync(generatedEntry, simulatorEntrySource(cwd, absEntry, manifest, backendPort))
|
|
408
537
|
const buildConfig = frontendBuildConfig(cwd, { ...(manifest.frontend || {}), entry })
|
|
409
538
|
|
|
410
539
|
const esbuild = loadEsbuild()
|
package/lib/manifest.js
CHANGED
|
@@ -49,7 +49,10 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
49
49
|
"database",
|
|
50
50
|
"scheduled_jobs",
|
|
51
51
|
"secrets",
|
|
52
|
+
"connections",
|
|
52
53
|
"platform_services",
|
|
54
|
+
"provides",
|
|
55
|
+
"requires",
|
|
53
56
|
])
|
|
54
57
|
|
|
55
58
|
function loadManifest(cwd) {
|
|
@@ -173,6 +176,205 @@ function validatePlatformServices(value, errors) {
|
|
|
173
176
|
}
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
const CONNECTION_PROVIDERS = new Set([
|
|
180
|
+
"google",
|
|
181
|
+
"instagram",
|
|
182
|
+
"slack",
|
|
183
|
+
"linear",
|
|
184
|
+
"hubspot",
|
|
185
|
+
"stripe",
|
|
186
|
+
"zendesk",
|
|
187
|
+
"custom",
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
function validateConnections(value, errors) {
|
|
191
|
+
if (value === undefined) return
|
|
192
|
+
if (!Array.isArray(value)) {
|
|
193
|
+
errors.push("connections must be an array")
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const seen = new Set()
|
|
197
|
+
value.forEach((conn, i) => {
|
|
198
|
+
const label = `connections[${i}]`
|
|
199
|
+
if (!isObject(conn)) {
|
|
200
|
+
errors.push(`${label} must be an object`)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
unknownKeys(conn, new Set(["id", "provider", "label", "auth", "scopes", "required"]), label, errors)
|
|
204
|
+
if (!conn.id || typeof conn.id !== "string" || !/^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(conn.id)) {
|
|
205
|
+
errors.push(`${label}.id must be lowercase snake/kebab case`)
|
|
206
|
+
} else if (seen.has(conn.id)) {
|
|
207
|
+
errors.push(`duplicate connection id: ${conn.id}`)
|
|
208
|
+
}
|
|
209
|
+
seen.add(conn.id)
|
|
210
|
+
if (!conn.provider || typeof conn.provider !== "string" || !CONNECTION_PROVIDERS.has(conn.provider)) {
|
|
211
|
+
errors.push(`${label}.provider must be one of ${Array.from(CONNECTION_PROVIDERS).join(", ")}`)
|
|
212
|
+
}
|
|
213
|
+
if (!conn.label || typeof conn.label !== "string") errors.push(`${label}.label is required`)
|
|
214
|
+
if (conn.auth !== undefined && conn.auth !== "oauth2" && conn.auth !== "api_key") {
|
|
215
|
+
errors.push(`${label}.auth must be oauth2 or api_key`)
|
|
216
|
+
}
|
|
217
|
+
if (conn.scopes !== undefined) {
|
|
218
|
+
if (!Array.isArray(conn.scopes)) {
|
|
219
|
+
errors.push(`${label}.scopes must be an array`)
|
|
220
|
+
} else {
|
|
221
|
+
const scopeSeen = new Set()
|
|
222
|
+
for (const scope of conn.scopes) {
|
|
223
|
+
if (typeof scope !== "string" || scope.trim() === "") {
|
|
224
|
+
errors.push(`${label}.scopes entries must be non-empty strings`)
|
|
225
|
+
} else if (scopeSeen.has(scope)) {
|
|
226
|
+
errors.push(`${label}.scopes contains duplicate scope: ${scope}`)
|
|
227
|
+
}
|
|
228
|
+
scopeSeen.add(scope)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
requireBoolean(conn, "required", label, errors)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isCapabilityId(value) {
|
|
237
|
+
return typeof value === "string" && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(value)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateServiceRoutes(value, label, errors) {
|
|
241
|
+
if (value === undefined) return
|
|
242
|
+
if (!Array.isArray(value)) {
|
|
243
|
+
errors.push(`${label}.routes must be an array`)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
247
|
+
value.forEach((route, i) => {
|
|
248
|
+
const routeLabel = `${label}.routes[${i}]`
|
|
249
|
+
if (!isObject(route)) {
|
|
250
|
+
errors.push(`${routeLabel} must be an object`)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
unknownKeys(route, new Set(["method", "path", "operation_id", "description"]), routeLabel, errors)
|
|
254
|
+
if (!methods.has(String(route.method || "").toUpperCase())) {
|
|
255
|
+
errors.push(`${routeLabel}.method must be one of ${Array.from(methods).join(", ")}`)
|
|
256
|
+
}
|
|
257
|
+
if (typeof route.path !== "string" || !route.path.startsWith("/")) {
|
|
258
|
+
errors.push(`${routeLabel}.path must start with '/'`)
|
|
259
|
+
}
|
|
260
|
+
requireString(route, "operation_id", routeLabel, errors)
|
|
261
|
+
requireString(route, "description", routeLabel, errors)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function validateProvides(value, errors) {
|
|
266
|
+
if (value === undefined) return
|
|
267
|
+
if (!isObject(value)) {
|
|
268
|
+
errors.push("provides must be an object")
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
unknownKeys(value, new Set(["services", "events"]), "provides", errors)
|
|
272
|
+
if (value.services !== undefined) {
|
|
273
|
+
if (!Array.isArray(value.services)) {
|
|
274
|
+
errors.push("provides.services must be an array")
|
|
275
|
+
} else {
|
|
276
|
+
const seen = new Set()
|
|
277
|
+
value.services.forEach((service, i) => {
|
|
278
|
+
const label = `provides.services[${i}]`
|
|
279
|
+
if (!isObject(service)) {
|
|
280
|
+
errors.push(`${label} must be an object`)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
unknownKeys(service, new Set(["id", "version", "label", "description", "permissions", "routes"]), label, errors)
|
|
284
|
+
if (!isCapabilityId(service.id)) {
|
|
285
|
+
errors.push(`${label}.id must be a dotted lowercase capability id`)
|
|
286
|
+
} else if (seen.has(service.id)) {
|
|
287
|
+
errors.push(`duplicate provided service id: ${service.id}`)
|
|
288
|
+
}
|
|
289
|
+
seen.add(service.id)
|
|
290
|
+
if (service.version !== undefined && !isSemverRange(service.version)) errors.push(`${label}.version must be a semver range`)
|
|
291
|
+
requireString(service, "label", label, errors)
|
|
292
|
+
requireString(service, "description", label, errors)
|
|
293
|
+
if (service.permissions !== undefined) {
|
|
294
|
+
if (!Array.isArray(service.permissions)) {
|
|
295
|
+
errors.push(`${label}.permissions must be an array`)
|
|
296
|
+
} else {
|
|
297
|
+
for (const permission of service.permissions) {
|
|
298
|
+
if (typeof permission !== "string" || !KNOWN_PERMISSIONS.has(permission)) {
|
|
299
|
+
errors.push(`${label}.permissions contains unknown permission: ${permission}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
validateServiceRoutes(service.routes, label, errors)
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (value.events !== undefined) {
|
|
309
|
+
if (!Array.isArray(value.events)) {
|
|
310
|
+
errors.push("provides.events must be an array")
|
|
311
|
+
} else {
|
|
312
|
+
for (const event of value.events) {
|
|
313
|
+
if (!isCapabilityId(event)) errors.push(`provides.events entries must be dotted lowercase topics: ${event}`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function validateRequires(value, errors) {
|
|
320
|
+
if (value === undefined) return
|
|
321
|
+
if (!isObject(value)) {
|
|
322
|
+
errors.push("requires must be an object")
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
unknownKeys(value, new Set(["apps", "services", "events"]), "requires", errors)
|
|
326
|
+
if (value.apps !== undefined) {
|
|
327
|
+
if (!Array.isArray(value.apps)) {
|
|
328
|
+
errors.push("requires.apps must be an array")
|
|
329
|
+
} else {
|
|
330
|
+
value.apps.forEach((dep, i) => {
|
|
331
|
+
const label = `requires.apps[${i}]`
|
|
332
|
+
if (!isObject(dep)) {
|
|
333
|
+
errors.push(`${label} must be an object`)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
unknownKeys(dep, new Set(["id", "version", "required", "reason"]), label, errors)
|
|
337
|
+
if (typeof dep.id !== "string" || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(dep.id)) {
|
|
338
|
+
errors.push(`${label}.id must be lowercase kebab-case`)
|
|
339
|
+
}
|
|
340
|
+
if (dep.version !== undefined && !isSemverRange(dep.version)) errors.push(`${label}.version must be a semver range`)
|
|
341
|
+
requireBoolean(dep, "required", label, errors)
|
|
342
|
+
requireString(dep, "reason", label, errors)
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (value.services !== undefined) {
|
|
347
|
+
if (!Array.isArray(value.services)) {
|
|
348
|
+
errors.push("requires.services must be an array")
|
|
349
|
+
} else {
|
|
350
|
+
value.services.forEach((dep, i) => {
|
|
351
|
+
const label = `requires.services[${i}]`
|
|
352
|
+
if (!isObject(dep)) {
|
|
353
|
+
errors.push(`${label} must be an object`)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
unknownKeys(dep, new Set(["id", "version", "required", "reason", "provider"]), label, errors)
|
|
357
|
+
if (!isCapabilityId(dep.id)) errors.push(`${label}.id must be a dotted lowercase capability id`)
|
|
358
|
+
if (dep.version !== undefined && !isSemverRange(dep.version)) errors.push(`${label}.version must be a semver range`)
|
|
359
|
+
requireBoolean(dep, "required", label, errors)
|
|
360
|
+
requireString(dep, "reason", label, errors)
|
|
361
|
+
if (dep.provider !== undefined && (typeof dep.provider !== "string" || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(dep.provider))) {
|
|
362
|
+
errors.push(`${label}.provider must be lowercase kebab-case`)
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (value.events !== undefined) {
|
|
368
|
+
if (!Array.isArray(value.events)) {
|
|
369
|
+
errors.push("requires.events must be an array")
|
|
370
|
+
} else {
|
|
371
|
+
for (const event of value.events) {
|
|
372
|
+
if (!isCapabilityId(event)) errors.push(`requires.events entries must be dotted lowercase topics: ${event}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
176
378
|
function validateManifest(m) {
|
|
177
379
|
const errors = []
|
|
178
380
|
if (!isObject(m)) return ["manifest must be an object"]
|
|
@@ -239,7 +441,10 @@ function validateManifest(m) {
|
|
|
239
441
|
}
|
|
240
442
|
|
|
241
443
|
validateSecrets(m.secrets, errors)
|
|
444
|
+
validateConnections(m.connections, errors)
|
|
242
445
|
validatePlatformServices(m.platform_services, errors)
|
|
446
|
+
validateProvides(m.provides, errors)
|
|
447
|
+
validateRequires(m.requires, errors)
|
|
243
448
|
|
|
244
449
|
if (m.sdk) {
|
|
245
450
|
if (!isObject(m.sdk)) errors.push("sdk must be an object")
|
package/package.json
CHANGED
|
@@ -2,3 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Calls a third-party API. Notice the manifest declares `capabilities.external_network`
|
|
4
4
|
— the platform enforces this allowlist at runtime.
|
|
5
|
+
|
|
6
|
+
It also declares a Palette-managed `connections` entry. In `pltt dev`, use
|
|
7
|
+
`.palette/connections.local.json` to mock connection states. After install,
|
|
8
|
+
org admins connect the real service from the app Settings > Connect tab.
|
|
@@ -20,9 +20,10 @@ EXTERNAL_HOST = "https://api.example.com"
|
|
|
20
20
|
async def proxy(ctx: PluginContext = Depends(get_plugin_context)) -> dict:
|
|
21
21
|
import httpx
|
|
22
22
|
|
|
23
|
+
connection = await ctx.connections.status("example_oauth")
|
|
23
24
|
token = os.environ.get("EXAMPLE_API_TOKEN")
|
|
24
25
|
if not token:
|
|
25
26
|
raise HTTPException(status_code=500, detail="EXAMPLE_API_TOKEN not configured")
|
|
26
27
|
async with httpx.AsyncClient() as client:
|
|
27
28
|
r = await client.get(f"{EXTERNAL_HOST}/v1/ping", headers={"Authorization": f"Bearer {token}"})
|
|
28
|
-
return {"upstream_status": r.status_code, "body": r.text[:200]}
|
|
29
|
+
return {"connection": connection.status, "upstream_status": r.status_code, "body": r.text[:200]}
|
|
@@ -22,5 +22,15 @@
|
|
|
22
22
|
},
|
|
23
23
|
"frontend": { "entry": "./frontend/src/index.tsx", "sandbox": true },
|
|
24
24
|
"backend": { "entry": "./backend/api/main.py" },
|
|
25
|
-
"permissions": ["resources:read"]
|
|
25
|
+
"permissions": ["resources:read"],
|
|
26
|
+
"connections": [
|
|
27
|
+
{
|
|
28
|
+
"id": "example_oauth",
|
|
29
|
+
"provider": "custom",
|
|
30
|
+
"label": "Example OAuth",
|
|
31
|
+
"auth": "oauth2",
|
|
32
|
+
"scopes": ["read"],
|
|
33
|
+
"required": false
|
|
34
|
+
}
|
|
35
|
+
]
|
|
26
36
|
}
|