@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 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.7"
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
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 vector services are declared in the manifest:
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
@@ -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 = None
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
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.12"
7
+ "@palettelab/sdk": "^0.1.15"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -13,7 +13,7 @@
13
13
  "text": "#fff"
14
14
  },
15
15
  "sdk": {
16
- "frontend": "^0.1.12",
16
+ "frontend": "^0.1.15",
17
17
  "backend": "^0.1.0"
18
18
  },
19
19
  "platform": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.12",
6
+ "@palettelab/sdk": "^0.1.15",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -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", "backend": "^0.1.0" },
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,
@@ -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.12", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.15", "react": "^19.0.0" }
6
6
  }
@@ -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", "backend": "^0.1.0" },
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,
@@ -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.12", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.15", "react": "^19.0.0" }
6
6
  }
@@ -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", "backend": "^0.1.0" },
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,
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.12",
6
+ "@palettelab/sdk": "^0.1.15",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -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" },
12
+ "sdk": { "frontend": "^0.1.15" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.12",
6
+ "@palettelab/sdk": "^0.1.15",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -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" },
12
+ "sdk": { "frontend": "^0.1.15" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,