@palettelab/cli 0.3.42 → 0.3.44
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 +5 -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 +89 -1
- package/lib/commands/test.js +13 -2
- package/lib/dev-simulator.js +243 -5
- 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.
|
|
@@ -327,6 +328,8 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
327
328
|
return {"room": room, "folder": folder, "bytes": len(content or b"")}
|
|
328
329
|
```
|
|
329
330
|
|
|
331
|
+
App storage is different from Data Room uploads. Use `ctx.storage` or `palette.storage` for app-owned files written directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` / `palette.dataRooms` only when the file should be managed as a Data Room document.
|
|
332
|
+
|
|
330
333
|
Python backend app-storage example:
|
|
331
334
|
|
|
332
335
|
```python
|
|
@@ -663,7 +666,7 @@ pltt test
|
|
|
663
666
|
pltt test --json
|
|
664
667
|
```
|
|
665
668
|
|
|
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
|
|
669
|
+
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
670
|
|
|
668
671
|
### `pltt package`
|
|
669
672
|
|
|
@@ -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,90 @@ 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
|
+
|
|
743
|
+
App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
|
|
744
|
+
|
|
657
745
|
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
658
746
|
frontend storage client live under:
|
|
659
747
|
|
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")
|
|
@@ -93,11 +145,13 @@ function ensurePythonEnv(cwd, devDir, manifest) {
|
|
|
93
145
|
return venvPython
|
|
94
146
|
}
|
|
95
147
|
|
|
96
|
-
function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
|
|
148
|
+
function writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort) {
|
|
97
149
|
const runner = path.join(devDir, "backend_runner.py")
|
|
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
|
|
@@ -105,10 +159,12 @@ import importlib.util
|
|
|
105
159
|
import json
|
|
106
160
|
import os
|
|
107
161
|
import pathlib
|
|
162
|
+
import re
|
|
108
163
|
import sys
|
|
164
|
+
import uuid
|
|
109
165
|
from types import SimpleNamespace
|
|
110
166
|
|
|
111
|
-
from fastapi import FastAPI, Request
|
|
167
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
112
168
|
from fastapi.middleware.cors import CORSMiddleware
|
|
113
169
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
114
170
|
|
|
@@ -119,6 +175,9 @@ SDK_PATH = ${JSON.stringify(sdkPath || "")}
|
|
|
119
175
|
DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities", {}).get("database"))
|
|
120
176
|
DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
|
|
121
177
|
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
178
|
+
DEV_CONNECTIONS = json.loads(${JSON.stringify(JSON.stringify(devConnections))})
|
|
179
|
+
DEV_APP_MOCKS = json.loads(${JSON.stringify(JSON.stringify(devAppMocks))})
|
|
180
|
+
BACKEND_BASE = "http://127.0.0.1:${backendPort}"
|
|
122
181
|
|
|
123
182
|
def _service_enabled(name: str) -> bool:
|
|
124
183
|
services = MANIFEST.get("platform_services") or []
|
|
@@ -153,6 +212,67 @@ if _service_enabled("storage"):
|
|
|
153
212
|
organization_name="Palette Dev",
|
|
154
213
|
)
|
|
155
214
|
|
|
215
|
+
LOCAL_UPLOADS = {}
|
|
216
|
+
CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
|
|
217
|
+
|
|
218
|
+
def _local_storage_enabled():
|
|
219
|
+
return _service_enabled("storage") and DEV_STORAGE is not None
|
|
220
|
+
|
|
221
|
+
def _parse_content_range(value: str | None):
|
|
222
|
+
if not value:
|
|
223
|
+
raise HTTPException(status_code=411, detail="Content-Range header is required")
|
|
224
|
+
match = CONTENT_RANGE_RE.match(value)
|
|
225
|
+
if not match:
|
|
226
|
+
raise HTTPException(status_code=400, detail="Invalid Content-Range header")
|
|
227
|
+
start = int(match.group("start"))
|
|
228
|
+
end = int(match.group("end"))
|
|
229
|
+
total = int(match.group("total"))
|
|
230
|
+
if end < start or total < 0 or end >= total:
|
|
231
|
+
raise HTTPException(status_code=400, detail="Invalid Content-Range byte range")
|
|
232
|
+
return start, end, total
|
|
233
|
+
|
|
234
|
+
def _require_local_storage(plugin_id: str):
|
|
235
|
+
if plugin_id != MANIFEST.get("id", ""):
|
|
236
|
+
raise HTTPException(status_code=404, detail="App not found")
|
|
237
|
+
if not _local_storage_enabled():
|
|
238
|
+
raise HTTPException(status_code=403, detail='App must declare platform_services: ["storage"]')
|
|
239
|
+
|
|
240
|
+
def _api_url(path: str):
|
|
241
|
+
return f"{BACKEND_BASE}/api/v1{path}"
|
|
242
|
+
|
|
243
|
+
class LocalAppInteropService:
|
|
244
|
+
def service(self, service_id: str):
|
|
245
|
+
return LocalAppServiceClient(service_id)
|
|
246
|
+
|
|
247
|
+
async def call(self, app_id: str, path: str, *, method: str = "GET", json_body=None, timeout: float = 30.0):
|
|
248
|
+
key = app_id + " " + method.upper() + " " + path
|
|
249
|
+
if key in DEV_APP_MOCKS:
|
|
250
|
+
return DEV_APP_MOCKS[key]
|
|
251
|
+
raise RuntimeError("No local app mock configured for " + key)
|
|
252
|
+
|
|
253
|
+
async def call_service(self, service_id: str, path: str, *, method: str = "GET", json_body=None, timeout: float = 30.0):
|
|
254
|
+
key = service_id + " " + method.upper() + " " + path
|
|
255
|
+
if key in DEV_APP_MOCKS:
|
|
256
|
+
return DEV_APP_MOCKS[key]
|
|
257
|
+
raise RuntimeError("No local app service mock configured for " + key)
|
|
258
|
+
|
|
259
|
+
class LocalAppServiceClient:
|
|
260
|
+
def __init__(self, service_id: str):
|
|
261
|
+
self.service_id = service_id
|
|
262
|
+
|
|
263
|
+
async def call(self, path: str, *, method: str = "GET", json=None, timeout: float = 30.0):
|
|
264
|
+
return await LocalAppInteropService().call_service(self.service_id, path, method=method, json_body=json, timeout=timeout)
|
|
265
|
+
|
|
266
|
+
async def get(self, path: str, **kwargs):
|
|
267
|
+
return await self.call(path, method="GET", **kwargs)
|
|
268
|
+
|
|
269
|
+
async def post(self, path: str, json=None, **kwargs):
|
|
270
|
+
return await self.call(path, method="POST", json=json, **kwargs)
|
|
271
|
+
|
|
272
|
+
class LocalEventPublisher:
|
|
273
|
+
async def publish(self, topic: str, payload=None):
|
|
274
|
+
print("[palette-event]", topic, json.dumps(payload or {}, sort_keys=True))
|
|
275
|
+
|
|
156
276
|
spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
|
|
157
277
|
module = importlib.util.module_from_spec(spec)
|
|
158
278
|
assert spec and spec.loader
|
|
@@ -190,6 +310,9 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
190
310
|
"secret_specs": MANIFEST.get("secrets") or {},
|
|
191
311
|
"secret_scope": "dev",
|
|
192
312
|
}
|
|
313
|
+
request.state.plugin_local_connections = DEV_CONNECTIONS
|
|
314
|
+
request.state.plugin_apps = LocalAppInteropService()
|
|
315
|
+
request.state.plugin_events = LocalEventPublisher()
|
|
193
316
|
request.state.storage = DEV_STORAGE
|
|
194
317
|
if DEV_REDIS is not None:
|
|
195
318
|
request.state.redis = DEV_REDIS
|
|
@@ -218,6 +341,84 @@ app.add_middleware(
|
|
|
218
341
|
app.add_middleware(DevPluginContextMiddleware)
|
|
219
342
|
app.include_router(router, prefix=f"/api/v1/plugins/{MANIFEST['id']}")
|
|
220
343
|
|
|
344
|
+
@app.post("/api/v1/app-storage/{plugin_id}/uploads")
|
|
345
|
+
async def create_local_app_storage_upload(plugin_id: str, request: Request):
|
|
346
|
+
_require_local_storage(plugin_id)
|
|
347
|
+
body = await request.json()
|
|
348
|
+
filename = body.get("filename") or "upload"
|
|
349
|
+
content_type = body.get("content_type") or "application/octet-stream"
|
|
350
|
+
size = int(body.get("size") or 0)
|
|
351
|
+
key = body.get("key")
|
|
352
|
+
chunk_size = int(body.get("chunk_size") or 8 * 1024 * 1024)
|
|
353
|
+
upload_id = uuid.uuid4().hex
|
|
354
|
+
object_path = DEV_STORAGE.object_path(filename, key=key)
|
|
355
|
+
temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
|
|
356
|
+
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
temp_path.write_bytes(b"")
|
|
358
|
+
LOCAL_UPLOADS[upload_id] = {
|
|
359
|
+
"plugin_id": plugin_id,
|
|
360
|
+
"object_path": object_path,
|
|
361
|
+
"content_type": content_type,
|
|
362
|
+
"size": size,
|
|
363
|
+
"uploaded_bytes": 0,
|
|
364
|
+
"complete": False,
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
"upload_id": upload_id,
|
|
368
|
+
"mode": "local_resumable",
|
|
369
|
+
"bucket": "local",
|
|
370
|
+
"object_path": object_path,
|
|
371
|
+
"file_url": DEV_STORAGE._target(object_path).as_uri(),
|
|
372
|
+
"upload_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/chunks"),
|
|
373
|
+
"status_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/status"),
|
|
374
|
+
"content_type": content_type,
|
|
375
|
+
"size": size,
|
|
376
|
+
"chunk_size": chunk_size,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@app.get("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/status")
|
|
380
|
+
async def local_app_storage_upload_status(plugin_id: str, upload_id: str):
|
|
381
|
+
_require_local_storage(plugin_id)
|
|
382
|
+
session = LOCAL_UPLOADS.get(upload_id)
|
|
383
|
+
if not session or session["plugin_id"] != plugin_id:
|
|
384
|
+
raise HTTPException(status_code=404, detail="Upload session not found")
|
|
385
|
+
return {
|
|
386
|
+
"upload_id": upload_id,
|
|
387
|
+
"object_path": session["object_path"],
|
|
388
|
+
"uploaded_bytes": session["uploaded_bytes"],
|
|
389
|
+
"size": session["size"],
|
|
390
|
+
"complete": session["complete"],
|
|
391
|
+
"file_url": DEV_STORAGE._target(session["object_path"]).as_uri(),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@app.put("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/chunks")
|
|
395
|
+
async def upload_local_app_storage_chunk(plugin_id: str, upload_id: str, request: Request):
|
|
396
|
+
_require_local_storage(plugin_id)
|
|
397
|
+
session = LOCAL_UPLOADS.get(upload_id)
|
|
398
|
+
if not session or session["plugin_id"] != plugin_id:
|
|
399
|
+
raise HTTPException(status_code=404, detail="Upload session not found")
|
|
400
|
+
if session["complete"]:
|
|
401
|
+
return Response(status_code=204)
|
|
402
|
+
start, end, total = _parse_content_range(request.headers.get("content-range"))
|
|
403
|
+
if total != session["size"]:
|
|
404
|
+
raise HTTPException(status_code=400, detail="Chunk total does not match upload size")
|
|
405
|
+
if start != session["uploaded_bytes"]:
|
|
406
|
+
raise HTTPException(status_code=409, detail=f"Expected chunk to start at byte {session['uploaded_bytes']}")
|
|
407
|
+
payload = await request.body()
|
|
408
|
+
if len(payload) != end - start + 1:
|
|
409
|
+
raise HTTPException(status_code=400, detail="Chunk size does not match Content-Range")
|
|
410
|
+
temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
|
|
411
|
+
with temp_path.open("ab") as fh:
|
|
412
|
+
fh.write(payload)
|
|
413
|
+
session["uploaded_bytes"] = end + 1
|
|
414
|
+
if session["uploaded_bytes"] >= session["size"]:
|
|
415
|
+
final_path = DEV_STORAGE._target(session["object_path"])
|
|
416
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
temp_path.replace(final_path)
|
|
418
|
+
session["complete"] = True
|
|
419
|
+
return Response(status_code=201)
|
|
420
|
+
return Response(status_code=308, headers={"Range": f"bytes=0-{session['uploaded_bytes'] - 1}"})
|
|
421
|
+
|
|
221
422
|
@app.on_event("startup")
|
|
222
423
|
async def create_local_database_tables():
|
|
223
424
|
if engine is None:
|
|
@@ -236,7 +437,7 @@ function startBackend(cwd, devDir, manifest, backendPort) {
|
|
|
236
437
|
if (!fs.existsSync(absEntry)) throw new Error(`backend entry not found: ${backendEntry}`)
|
|
237
438
|
|
|
238
439
|
const python = ensurePythonEnv(cwd, devDir, manifest)
|
|
239
|
-
const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry)
|
|
440
|
+
const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort)
|
|
240
441
|
const sdkPath = localBackendSdkPath()
|
|
241
442
|
const env = { ...process.env }
|
|
242
443
|
if (sdkPath) {
|
|
@@ -251,7 +452,8 @@ function startBackend(cwd, devDir, manifest, backendPort) {
|
|
|
251
452
|
return child
|
|
252
453
|
}
|
|
253
454
|
|
|
254
|
-
function simulatorEntrySource(pluginEntry, manifest, backendPort) {
|
|
455
|
+
function simulatorEntrySource(cwd, pluginEntry, manifest, backendPort) {
|
|
456
|
+
const localConnections = loadLocalConnections(cwd, manifest)
|
|
255
457
|
return `
|
|
256
458
|
import React from "react"
|
|
257
459
|
import { createRoot } from "react-dom/client"
|
|
@@ -259,9 +461,45 @@ import { PluginProvider } from "@palettelab/sdk"
|
|
|
259
461
|
import Plugin from ${JSON.stringify(pluginEntry)}
|
|
260
462
|
|
|
261
463
|
const backendBase = "http://127.0.0.1:${backendPort}"
|
|
464
|
+
const localConnections = ${JSON.stringify(localConnections)}
|
|
465
|
+
|
|
466
|
+
function connectionListResponse() {
|
|
467
|
+
return new Response(JSON.stringify(Object.values(localConnections)), {
|
|
468
|
+
status: 200,
|
|
469
|
+
headers: { "Content-Type": "application/json" },
|
|
470
|
+
})
|
|
471
|
+
}
|
|
262
472
|
|
|
263
473
|
async function apiFetch(path, init) {
|
|
264
474
|
const target = String(path || "")
|
|
475
|
+
const method = String(init?.method || "GET").toUpperCase()
|
|
476
|
+
const connectionPrefix = "/api/v1/app-installs/" + encodeURIComponent(${JSON.stringify(manifest.id)}) + "/connections"
|
|
477
|
+
if (target === connectionPrefix && method === "GET") {
|
|
478
|
+
return connectionListResponse()
|
|
479
|
+
}
|
|
480
|
+
if (target.startsWith(connectionPrefix + "/") && target.endsWith("/authorize") && method === "POST") {
|
|
481
|
+
const id = decodeURIComponent(target.slice((connectionPrefix + "/").length, -"/authorize".length))
|
|
482
|
+
if (localConnections[id]) {
|
|
483
|
+
localConnections[id] = {
|
|
484
|
+
...localConnections[id],
|
|
485
|
+
status: "connected",
|
|
486
|
+
account_label: localConnections[id].account_label || "Local mock account",
|
|
487
|
+
granted_scopes: localConnections[id].granted_scopes?.length ? localConnections[id].granted_scopes : localConnections[id].scopes,
|
|
488
|
+
connected_at: new Date().toISOString(),
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return new Response(JSON.stringify({ connection_id: id, authorize_url: "palette-local://connected", mode: "mock" }), {
|
|
492
|
+
status: 200,
|
|
493
|
+
headers: { "Content-Type": "application/json" },
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
if (target.startsWith(connectionPrefix + "/") && method === "DELETE") {
|
|
497
|
+
const id = decodeURIComponent(target.slice((connectionPrefix + "/").length))
|
|
498
|
+
if (localConnections[id]) {
|
|
499
|
+
localConnections[id] = { ...localConnections[id], status: "available", account_label: null, connected_at: null, granted_scopes: [] }
|
|
500
|
+
}
|
|
501
|
+
return new Response(null, { status: 204 })
|
|
502
|
+
}
|
|
265
503
|
if (target.startsWith("http://") || target.startsWith("https://")) {
|
|
266
504
|
return fetch(target, init)
|
|
267
505
|
}
|
|
@@ -404,7 +642,7 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
|
404
642
|
if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
|
|
405
643
|
const generatedEntry = path.join(devDir, "simulator-entry.jsx")
|
|
406
644
|
const bundlePath = path.join(devDir, "simulator.js")
|
|
407
|
-
fs.writeFileSync(generatedEntry, simulatorEntrySource(absEntry, manifest, backendPort))
|
|
645
|
+
fs.writeFileSync(generatedEntry, simulatorEntrySource(cwd, absEntry, manifest, backendPort))
|
|
408
646
|
const buildConfig = frontendBuildConfig(cwd, { ...(manifest.frontend || {}), entry })
|
|
409
647
|
|
|
410
648
|
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
|
}
|