@palettelab/cli 0.3.36 → 0.3.37
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 +19 -0
- package/backend-sdk/palette_sdk/__init__.py +3 -1
- package/backend-sdk/palette_sdk/plugin_context.py +1 -1
- package/backend-sdk/palette_sdk/storage.py +105 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +39 -2
- package/lib/dev-simulator.js +12 -1
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/palette-plugin.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/database/palette-plugin.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/external-service/palette-plugin.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/next/palette-plugin.json +1 -1
package/README.md
CHANGED
|
@@ -288,6 +288,7 @@ Backend SDK features for app-owned data:
|
|
|
288
288
|
- `require_permission(permission)`, `KNOWN_PERMISSIONS`, and `is_known_permission(permission)` support route and manifest permission checks.
|
|
289
289
|
- `ctx.redis` gives a Redis-backed, plugin/org-scoped Redis API when `"redis"` is declared in `platform_services`.
|
|
290
290
|
- `ctx.vector` gives a Qdrant-backed, plugin/org-scoped vector API when `"vector"` is declared in `platform_services`.
|
|
291
|
+
- `ctx.storage` gives app/org-scoped file upload helpers when `"storage"` is declared in `platform_services`.
|
|
291
292
|
- `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
|
|
292
293
|
- `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
|
|
293
294
|
- `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)`, and `ensure_org_rls(...)` keep database names and row-level security consistent.
|
|
@@ -326,6 +327,24 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
326
327
|
return {"room": room, "folder": folder, "bytes": len(content or b"")}
|
|
327
328
|
```
|
|
328
329
|
|
|
330
|
+
Python backend app-storage example:
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
@router.post("/reports", dependencies=[require_permission("reports:write")])
|
|
334
|
+
async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
|
|
335
|
+
saved = await ctx.storage.upload_file(
|
|
336
|
+
"summary.json",
|
|
337
|
+
b'{"ok": true}',
|
|
338
|
+
"application/json",
|
|
339
|
+
key="reports/summary.json",
|
|
340
|
+
)
|
|
341
|
+
return saved
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Frontend apps can use `createPaletteClient(platform).storage.upload(file, {
|
|
345
|
+
onProgress })`. Palette writes every object under the app folder and current
|
|
346
|
+
organisation folder, then uses GCS resumable uploads in hosted environments.
|
|
347
|
+
|
|
329
348
|
The npm `@palettelab/sdk` package is for frontend JavaScript/React apps.
|
|
330
349
|
Python backend code uses `palette_sdk`, which is embedded in the CLI for
|
|
331
350
|
local dev/tests and injected by the hosted Palette runtime.
|
|
@@ -36,6 +36,7 @@ from palette_sdk.events import Event, subscribe_event
|
|
|
36
36
|
from palette_sdk.config import get_config, require_config
|
|
37
37
|
from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
|
|
38
38
|
from palette_sdk.testing import route_permission_issues
|
|
39
|
+
from palette_sdk.storage import LocalStorageService
|
|
39
40
|
|
|
40
41
|
__all__ = [
|
|
41
42
|
"PluginRouter",
|
|
@@ -72,6 +73,7 @@ __all__ = [
|
|
|
72
73
|
"sign_webhook",
|
|
73
74
|
"verify_webhook_signature",
|
|
74
75
|
"route_permission_issues",
|
|
76
|
+
"LocalStorageService",
|
|
75
77
|
]
|
|
76
78
|
|
|
77
|
-
__version__ = "0.1.
|
|
79
|
+
__version__ = "0.1.8"
|
|
@@ -111,7 +111,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
111
111
|
org_role=getattr(state, "org_role", None),
|
|
112
112
|
plugin_id=getattr(state, "plugin_id", ""),
|
|
113
113
|
permissions=getattr(state, "plugin_permissions", []),
|
|
114
|
-
storage=getattr(state, "storage", None),
|
|
114
|
+
storage=getattr(state, "storage", None) or UnavailablePlatformService("storage"),
|
|
115
115
|
data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
|
|
116
116
|
members=OrganizationMembersClient(
|
|
117
117
|
getattr(state, "org_members", None),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _slug_segment(value: str | None, fallback: str) -> str:
|
|
12
|
+
raw = (value or fallback).strip().lower()
|
|
13
|
+
slug = re.sub(r"[^a-z0-9]+", "_", raw).strip("_")
|
|
14
|
+
return slug or fallback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sanitize_filename(filename: str | None) -> str:
|
|
18
|
+
raw = (filename or "upload").strip().replace("\\", "/").rsplit("/", 1)[-1]
|
|
19
|
+
safe = re.sub(r"[^\w.\- ]+", "", raw)
|
|
20
|
+
safe = re.sub(r"\s+", "_", safe).strip("._")
|
|
21
|
+
return safe or "upload"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_relative_key(key: str) -> str:
|
|
25
|
+
key = key.strip().replace("\\", "/")
|
|
26
|
+
if not key or key.startswith("/") or key.endswith("/"):
|
|
27
|
+
raise ValueError("key must be a non-empty relative file path")
|
|
28
|
+
parts = [part for part in key.split("/") if part]
|
|
29
|
+
if any(part in {".", ".."} for part in parts):
|
|
30
|
+
raise ValueError("key cannot contain relative path segments")
|
|
31
|
+
return "/".join(_sanitize_filename(part) for part in parts)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _content_type(filename: str | None, content_type: str | None) -> str:
|
|
35
|
+
browser_ct = content_type or "application/octet-stream"
|
|
36
|
+
if browser_ct == "application/octet-stream":
|
|
37
|
+
guessed, _ = mimetypes.guess_type(filename or "")
|
|
38
|
+
return guessed or browser_ct
|
|
39
|
+
return browser_ct
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LocalStorageService:
|
|
44
|
+
"""Filesystem-backed ctx.storage implementation used by local SDK dev."""
|
|
45
|
+
|
|
46
|
+
root: str | Path
|
|
47
|
+
plugin_id: str
|
|
48
|
+
app_name: str | None = None
|
|
49
|
+
organization_id: int = 1
|
|
50
|
+
organization_slug: str | None = "palette-dev"
|
|
51
|
+
organization_name: str | None = "Palette Dev"
|
|
52
|
+
|
|
53
|
+
def _prefix(self) -> str:
|
|
54
|
+
app_folder = f"{_slug_segment(self.app_name or self.plugin_id, 'app')}_{_slug_segment(self.plugin_id, 'plugin')}"
|
|
55
|
+
org_label = self.organization_slug or self.organization_name or f"org_{self.organization_id}"
|
|
56
|
+
org_folder = f"{_slug_segment(org_label, 'organisation')}_{self.organization_id}"
|
|
57
|
+
return f"uploads/apps/{app_folder}/{org_folder}"
|
|
58
|
+
|
|
59
|
+
def object_path(self, filename: str | None = None, *, key: str | None = None) -> str:
|
|
60
|
+
relative = _validate_relative_key(key) if key else f"{uuid.uuid4().hex}_{_sanitize_filename(filename)}"
|
|
61
|
+
return f"{self._prefix()}/{relative}"
|
|
62
|
+
|
|
63
|
+
def _target(self, object_path: str) -> Path:
|
|
64
|
+
root = Path(self.root).resolve()
|
|
65
|
+
target = (root / object_path).resolve()
|
|
66
|
+
target.relative_to(root)
|
|
67
|
+
return target
|
|
68
|
+
|
|
69
|
+
async def upload_file(
|
|
70
|
+
self,
|
|
71
|
+
filename: str,
|
|
72
|
+
content: bytes,
|
|
73
|
+
content_type: str | None = None,
|
|
74
|
+
*,
|
|
75
|
+
key: str | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
object_path = self.object_path(filename, key=key)
|
|
78
|
+
target = self._target(object_path)
|
|
79
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
target.write_bytes(content)
|
|
81
|
+
return {
|
|
82
|
+
"bucket": "local",
|
|
83
|
+
"object_path": object_path,
|
|
84
|
+
"file_url": target.as_uri(),
|
|
85
|
+
"content_type": _content_type(filename, content_type),
|
|
86
|
+
"size": len(content),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async def create_resumable_upload(
|
|
90
|
+
self,
|
|
91
|
+
filename: str,
|
|
92
|
+
content_type: str | None = None,
|
|
93
|
+
size: int | None = None,
|
|
94
|
+
*,
|
|
95
|
+
key: str | None = None,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
object_path = self.object_path(filename, key=key)
|
|
98
|
+
return {
|
|
99
|
+
"bucket": "local",
|
|
100
|
+
"object_path": object_path,
|
|
101
|
+
"file_url": self._target(object_path).as_uri(),
|
|
102
|
+
"upload_url": None,
|
|
103
|
+
"content_type": _content_type(filename, content_type),
|
|
104
|
+
"size": size,
|
|
105
|
+
}
|
|
@@ -631,11 +631,11 @@ install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
|
|
|
631
631
|
during `pltt dev`. Undeclared keys still fall back to the process environment
|
|
632
632
|
for local compatibility.
|
|
633
633
|
|
|
634
|
-
Managed Redis and
|
|
634
|
+
Managed Redis, vector, and storage services are declared in the manifest:
|
|
635
635
|
|
|
636
636
|
```json
|
|
637
637
|
{
|
|
638
|
-
"platform_services": ["redis", "vector"]
|
|
638
|
+
"platform_services": ["redis", "vector", "storage"]
|
|
639
639
|
}
|
|
640
640
|
```
|
|
641
641
|
|
|
@@ -654,6 +654,43 @@ Palette scopes every Redis key and vector operation by `plugin_id` and
|
|
|
654
654
|
`organization_id`; hosted previews also include the publish id. Plugin code
|
|
655
655
|
cannot read, list, update, or delete records owned by another app or org.
|
|
656
656
|
|
|
657
|
+
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
658
|
+
frontend storage client live under:
|
|
659
|
+
|
|
660
|
+
```text
|
|
661
|
+
uploads/apps/{app_name}_{plugin_id}/{organisation_slug}_{organisation_id}/{file}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Backend storage helpers:
|
|
665
|
+
|
|
666
|
+
```python
|
|
667
|
+
saved = await ctx.storage.upload_file(
|
|
668
|
+
"summary.json",
|
|
669
|
+
b'{"ok": true}',
|
|
670
|
+
"application/json",
|
|
671
|
+
key="reports/summary.json",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
session = await ctx.storage.create_resumable_upload(
|
|
675
|
+
"video.mp4",
|
|
676
|
+
"video/mp4",
|
|
677
|
+
size=video_size,
|
|
678
|
+
key="videos/video.mp4",
|
|
679
|
+
)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Frontend apps can use the browser SDK for chunked/resumable upload progress:
|
|
683
|
+
|
|
684
|
+
```tsx
|
|
685
|
+
const palette = createPaletteClient(platform)
|
|
686
|
+
|
|
687
|
+
await palette.storage.upload(file, {
|
|
688
|
+
key: `videos/${file.name}`,
|
|
689
|
+
chunkSize: 8 * 1024 * 1024,
|
|
690
|
+
onProgress: (p) => setPercent(p.percentage),
|
|
691
|
+
})
|
|
692
|
+
```
|
|
693
|
+
|
|
657
694
|
Advanced provider features are still available through scoped helpers:
|
|
658
695
|
`ctx.redis.execute(...)` forwards Redis data-plane commands after key rewriting,
|
|
659
696
|
while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
|
package/lib/dev-simulator.js
CHANGED
|
@@ -134,12 +134,23 @@ sys.path.insert(0, str(ENTRY.parent))
|
|
|
134
134
|
|
|
135
135
|
DEV_REDIS = None
|
|
136
136
|
DEV_VECTOR = None
|
|
137
|
+
DEV_STORAGE = None
|
|
137
138
|
if _service_enabled("redis"):
|
|
138
139
|
from palette_sdk.platform_services import LocalRedisService
|
|
139
140
|
DEV_REDIS = LocalRedisService()
|
|
140
141
|
if _service_enabled("vector"):
|
|
141
142
|
from palette_sdk.platform_services import LocalVectorService
|
|
142
143
|
DEV_VECTOR = LocalVectorService()
|
|
144
|
+
if _service_enabled("storage"):
|
|
145
|
+
from palette_sdk.storage import LocalStorageService
|
|
146
|
+
DEV_STORAGE = LocalStorageService(
|
|
147
|
+
ROOT / ".palette" / "dev-storage",
|
|
148
|
+
MANIFEST.get("id", ""),
|
|
149
|
+
app_name=MANIFEST.get("name"),
|
|
150
|
+
organization_id=1,
|
|
151
|
+
organization_slug="palette-dev",
|
|
152
|
+
organization_name="Palette Dev",
|
|
153
|
+
)
|
|
143
154
|
|
|
144
155
|
spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
|
|
145
156
|
module = importlib.util.module_from_spec(spec)
|
|
@@ -178,7 +189,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
178
189
|
"secret_specs": MANIFEST.get("secrets") or {},
|
|
179
190
|
"secret_scope": "dev",
|
|
180
191
|
}
|
|
181
|
-
request.state.storage =
|
|
192
|
+
request.state.storage = DEV_STORAGE
|
|
182
193
|
if DEV_REDIS is not None:
|
|
183
194
|
request.state.redis = DEV_REDIS
|
|
184
195
|
if DEV_VECTOR is not None:
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "A widget that exposes a dashboard data source and renders a chart from it.",
|
|
10
10
|
"icon": "ChartBar",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #06B6D4, #6366F1)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.15", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Stores notes per organization with RLS-enforced isolation.",
|
|
10
10
|
"icon": "Database",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #8B5CF6, #EC4899)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.15", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Demonstrates declared external_network access and a scoped per-org config token.",
|
|
10
10
|
"icon": "CloudArrowUp",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #10B981, #06B6D4)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.15", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "A frontend-only plugin — renders inside the platform iframe sandbox with no backend.",
|
|
10
10
|
"icon": "Puzzle",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #6366F1, #8B5CF6)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.15" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Uses frontend.framework=next so pltt reads frontend/next.config.ts while still publishing a native Palette module.",
|
|
10
10
|
"icon": "Puzzle",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.15" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|