@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 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 limit is 2 MiB; set `PALETTE_MAX_FRONTEND_BUNDLE_BYTES` for reviewed exceptions.
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 platform event subscriptions |
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
 
@@ -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 = 2 * 1024 * 1024
12
- const DEFAULT_BACKEND_BUNDLE_LIMIT = 5 * 1024 * 1024
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",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.42",
3
+ "version": "0.3.44",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.16"
7
+ "@palettelab/sdk": "^0.1.17"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "agents": [],
44
44
  "tools": [],
45
+ "connections": [],
45
46
  "permissions": [
46
47
  "data_rooms:read",
47
48
  "tasks:read",
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.16",
6
+ "@palettelab/sdk": "^0.1.17",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.16", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.17", "react": "^19.0.0" }
6
6
  }
@@ -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]}
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.16", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.17", "react": "^19.0.0" }
6
6
  }
@@ -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
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.16",
6
+ "@palettelab/sdk": "^0.1.17",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.16",
6
+ "@palettelab/sdk": "^0.1.17",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.16",
6
+ "@palettelab/sdk": "^0.1.17",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {