@palettelab/cli 0.3.35 → 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
@@ -275,7 +275,8 @@ async def create_invoice(body: InvoiceIn, ctx: PluginContext = Depends(get_plugi
275
275
 
276
276
  Backend SDK features for app-owned data:
277
277
 
278
- - `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, `ctx.db`, `ctx.members`, `ctx.redis`, and `ctx.vector`.
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
280
  - `ctx.db` is the full scoped SQLAlchemy `AsyncSession` for app-owned database data.
280
281
  - `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
281
282
  - `ctx.data_rooms` gives backend access to Palette Data Rooms without importing platform internals.
@@ -283,10 +284,20 @@ Backend SDK features for app-owned data:
283
284
  - `ctx.has_permission("...")`, `ctx.has_any_permission([...])`, and `ctx.has_all_permissions([...])` check declared permissions.
284
285
  - `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
285
286
  - `ctx.secret("KEY")` reads app secrets from config or environment variables.
287
+ - `get_config(ctx, key)` and `require_config(ctx, key)` are functional config helper forms.
288
+ - `require_permission(permission)`, `KNOWN_PERMISSIONS`, and `is_known_permission(permission)` support route and manifest permission checks.
286
289
  - `ctx.redis` gives a Redis-backed, plugin/org-scoped Redis API when `"redis"` is declared in `platform_services`.
287
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`.
288
292
  - `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
289
293
  - `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
294
+ - `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)`, and `ensure_org_rls(...)` keep database names and row-level security consistent.
295
+ - `Event` and `subscribe_event(...)` register in-process platform event handlers.
296
+ - `sign_webhook(...)` and `verify_webhook_signature(...)` handle HMAC-SHA256 webhook signing checks.
297
+ - `ToolDefinition` is the base class for custom agent tools.
298
+ - `PluginManifest` and `load_manifest(...)` parse and validate `palette-plugin.json`.
299
+ - `SuccessResponse`, `ErrorResponse`, and `PaginatedResponse` are reusable response schemas.
300
+ - `route_permission_issues(router, public_routes=None)` is the test helper for detecting ungated backend routes.
290
301
 
291
302
  Python backend Data Room example:
292
303
 
@@ -316,6 +327,24 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
316
327
  return {"room": room, "folder": folder, "bytes": len(content or b"")}
317
328
  ```
318
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
+
319
348
  The npm `@palettelab/sdk` package is for frontend JavaScript/React apps.
320
349
  Python backend code uses `palette_sdk`, which is embedded in the CLI for
321
350
  local dev/tests and injected by the hosted Palette runtime.
@@ -524,7 +553,7 @@ await ctx.redis.incr("counter")
524
553
  await ctx.redis.decr("counter")
525
554
  await ctx.redis.scan(prefix="cache:", limit=100)
526
555
 
527
- # Redis hashes, lists, sets, sorted sets, streams, locks
556
+ # Redis hashes, lists, sets, sorted sets, queues, locks
528
557
  await ctx.redis.hset("hash", "field", {"value": 1})
529
558
  await ctx.redis.hgetall("hash")
530
559
  await ctx.redis.lpush("queue", {"job": 1})
@@ -533,8 +562,8 @@ await ctx.redis.sadd("tags", "red", "blue")
533
562
  await ctx.redis.smembers("tags")
534
563
  await ctx.redis.zadd("scores", {"alice": 10})
535
564
  await ctx.redis.zrange("scores", 0, -1, with_scores=True)
536
- await ctx.redis.xadd("events", {"type": "created"})
537
- await ctx.redis.xread({"events": "0-0"}, count=10)
565
+ await ctx.redis.enqueue("jobs", {"task": "sync"})
566
+ await ctx.redis.dequeue("jobs")
538
567
  await ctx.redis.lock("invoice:1", token, ttl=30)
539
568
  await ctx.redis.unlock("invoice:1", token)
540
569
 
@@ -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"
@@ -125,6 +125,34 @@ Available context values:
125
125
  | `ctx.require_config(key)` | Read required config or raise |
126
126
  | `ctx.secret(key, default)` | Read a secret from app config or environment |
127
127
 
128
+ ## 4a. Backend Helper API Index
129
+
130
+ These are the public Python helpers exported by `palette_sdk`.
131
+
132
+ | Helper | Use it for |
133
+ |---|---|
134
+ | `PluginRouter` | FastAPI router mounted under `/api/v1/plugins/<plugin-id>` |
135
+ | `PluginContext`, `get_plugin_context` | Authenticated request context dependency |
136
+ | `MissingSecretError` | Handling missing required declared secrets from `ctx.secret(...)` |
137
+ | `require_permission(permission)` | Route-level permission gate required for protected routes |
138
+ | `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
139
+ | `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
140
+ | `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
141
+ | `OrgRepository`, `ctx.repo(Model)` | Org-safe convenience CRUD for app-owned models |
142
+ | `PluginBase`, `OrgScopedTable` | SQLAlchemy declarative bases for plugin-owned tables |
143
+ | `ensure_org_rls(op, table)` | Alembic helper that enables org row-level security |
144
+ | `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)` | Manifest id to database-safe naming helpers |
145
+ | `get_config(ctx, key)`, `require_config(ctx, key)` | Functional form of config reads when not using `ctx.config_value(...)` |
146
+ | `LocalRedisService`, `LocalVectorService` | Local `pltt dev` service emulators and test fakes |
147
+ | `PlatformServiceUnavailable`, `UnavailablePlatformService` | Clear errors when an undeclared platform service is used |
148
+ | `LifecycleHooks` | Install/update/enable/disable/uninstall callbacks |
149
+ | `Event`, `subscribe_event(...)` | In-process platform event subscriptions |
150
+ | `sign_webhook(...)`, `verify_webhook_signature(...)` | HMAC-SHA256 webhook signing and verification |
151
+ | `ToolDefinition` | Base class for custom agent tools |
152
+ | `PluginManifest`, `load_manifest(...)` | Typed manifest parsing from `palette-plugin.json` |
153
+ | `SuccessResponse`, `ErrorResponse`, `PaginatedResponse` | Common response schemas for plugin APIs |
154
+ | `route_permission_issues(router, public_routes=None)` | Test helper that reports routes missing `require_permission(...)` |
155
+
128
156
  ## 5. Permissions
129
157
 
130
158
  Use route-level permission guards for normal APIs:
@@ -603,11 +631,11 @@ install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
603
631
  during `pltt dev`. Undeclared keys still fall back to the process environment
604
632
  for local compatibility.
605
633
 
606
- Managed Redis and vector services are declared in the manifest:
634
+ Managed Redis, vector, and storage services are declared in the manifest:
607
635
 
608
636
  ```json
609
637
  {
610
- "platform_services": ["redis", "vector"]
638
+ "platform_services": ["redis", "vector", "storage"]
611
639
  }
612
640
  ```
613
641
 
@@ -626,6 +654,43 @@ Palette scopes every Redis key and vector operation by `plugin_id` and
626
654
  `organization_id`; hosted previews also include the publish id. Plugin code
627
655
  cannot read, list, update, or delete records owned by another app or org.
628
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
+
629
694
  Advanced provider features are still available through scoped helpers:
630
695
  `ctx.redis.execute(...)` forwards Redis data-plane commands after key rewriting,
631
696
  while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
@@ -666,8 +731,6 @@ await ctx.redis.zrem("scores", "bob")
666
731
 
667
732
  await ctx.redis.enqueue("jobs", {"task": "sync"})
668
733
  await ctx.redis.dequeue("jobs")
669
- await ctx.redis.xadd("events", {"type": "created"})
670
- await ctx.redis.xread({"events": "0-0"}, count=10)
671
734
  await ctx.redis.lock("invoice:1", token, ttl=30)
672
735
  await ctx.redis.unlock("invoice:1", token)
673
736
  ```
@@ -747,12 +810,10 @@ Frontend code should call backend routes through the platform API helper, not by
747
810
  hardcoding backend origins.
748
811
 
749
812
  ```tsx
750
- import { createPaletteClient } from "@palettelab/sdk"
751
-
752
- const palette = createPaletteClient()
813
+ import { apiFetch } from "@palettelab/sdk"
753
814
 
754
815
  async function loadInvoices() {
755
- const res = await palette.apiFetch("/api/v1/plugins/finance-tools/invoices")
816
+ const res = await apiFetch("/api/v1/plugins/finance-tools/invoices")
756
817
  return res.json()
757
818
  }
758
819
  ```
@@ -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.35",
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,