@palettelab/cli 0.3.54 → 0.3.55
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 +50 -0
- package/backend-sdk/palette_sdk/__init__.py +2 -0
- package/backend-sdk/palette_sdk/notifications.py +67 -0
- package/backend-sdk/palette_sdk/plugin_context.py +4 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +46 -0
- package/lib/commands/publish.js +2 -0
- package/lib/dev-simulator.js +43 -0
- package/lib/manifest.js +4 -0
- package/package.json +1 -1
- package/template-fallback/templates/consumer-app/package.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/palette-app/package.json +1 -1
- package/template-fallback/templates/provider-app/package.json +1 -1
package/README.md
CHANGED
|
@@ -396,6 +396,56 @@ The CLI validates manifest shape, SDK compatibility, frontend bundling, backend
|
|
|
396
396
|
imports, backend route permission gates, declared permissions, migration safety,
|
|
397
397
|
package dependency policy, and backend package size.
|
|
398
398
|
|
|
399
|
+
## OS Notifications
|
|
400
|
+
|
|
401
|
+
Apps can push persistent notifications into the Palette OS notification center
|
|
402
|
+
(the bell) with `@palettelab/sdk@0.1.25+`:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { notifications } from "@palettelab/sdk"
|
|
406
|
+
// or: palette.notifications.push(...)
|
|
407
|
+
|
|
408
|
+
await notifications.push({
|
|
409
|
+
title: "Export complete",
|
|
410
|
+
body: "Your report is ready to download.",
|
|
411
|
+
severity: "success", // "info" | "success" | "warning" | "error"
|
|
412
|
+
route: "/exports/123", // opened inside your app on click
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The notification shows as a live toast plus a notification-center entry;
|
|
417
|
+
clicking it opens/focuses your app window at the resolved route. The stable
|
|
418
|
+
contract is `POST /api/v1/notifications/` on the platform API (delivery streams
|
|
419
|
+
over `GET /api/v1/notifications/stream`, SSE).
|
|
420
|
+
|
|
421
|
+
Python plugin backends can push too, via `ctx.notifications` (palette-sdk
|
|
422
|
+
`0.1.9+`):
|
|
423
|
+
|
|
424
|
+
```python
|
|
425
|
+
from palette_sdk import PluginContext, get_plugin_context, require_permission
|
|
426
|
+
|
|
427
|
+
@router.post("/exports", dependencies=[require_permission("resources:write")])
|
|
428
|
+
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
429
|
+
...
|
|
430
|
+
await ctx.notifications.push(
|
|
431
|
+
"Export complete",
|
|
432
|
+
body="Your report is ready to download.",
|
|
433
|
+
severity="success",
|
|
434
|
+
route="/exports/123",
|
|
435
|
+
)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
By default the backend helper notifies the user making the current request;
|
|
439
|
+
pass `user_id` to notify another member of the same organisation. See
|
|
440
|
+
`docs/python-backend-sdk.md` ("OS Notifications From Python") for details.
|
|
441
|
+
|
|
442
|
+
During `pltt dev`, frontend pushes call the platform backend directly
|
|
443
|
+
(`NEXT_PUBLIC_API_URL`, default `http://localhost:8000`) in the user's session,
|
|
444
|
+
so they land in the real notification pool. Backend pushes run against the
|
|
445
|
+
local simulator, which logs them as `[palette-notification] {...}` and returns
|
|
446
|
+
a stub. Sandboxed iframe apps are not supported yet (no session cookie inside
|
|
447
|
+
the iframe).
|
|
448
|
+
|
|
399
449
|
## Commands
|
|
400
450
|
|
|
401
451
|
### `pltt init <name>`
|
|
@@ -6,6 +6,7 @@ from palette_sdk.data_rooms import DataRoomsClient
|
|
|
6
6
|
from palette_sdk.connections import ConnectionStatus, MissingConnectionError, PluginConnectionsClient
|
|
7
7
|
from palette_sdk.apps import AppInteropClient, AppServiceClient, MissingAppServiceError
|
|
8
8
|
from palette_sdk.members import OrganizationMembersClient
|
|
9
|
+
from palette_sdk.notifications import NotificationsClient
|
|
9
10
|
from palette_sdk.platform_services import (
|
|
10
11
|
LocalRedisService,
|
|
11
12
|
LocalVectorService,
|
|
@@ -62,6 +63,7 @@ __all__ = [
|
|
|
62
63
|
"AppServiceClient",
|
|
63
64
|
"MissingAppServiceError",
|
|
64
65
|
"OrganizationMembersClient",
|
|
66
|
+
"NotificationsClient",
|
|
65
67
|
"LocalRedisService",
|
|
66
68
|
"LocalVectorService",
|
|
67
69
|
"PlatformServiceUnavailable",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Backend OS notification helpers for plugin Python code.
|
|
2
|
+
|
|
3
|
+
Push a persistent notification into the OS notification center from a plugin
|
|
4
|
+
backend — the Python counterpart of the frontend SDK's `notifications.push()`:
|
|
5
|
+
|
|
6
|
+
from palette_sdk import PluginContext, get_plugin_context
|
|
7
|
+
|
|
8
|
+
@router.post("/exports", dependencies=[require_permission("resources:write")])
|
|
9
|
+
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
10
|
+
...
|
|
11
|
+
await ctx.notifications.push(
|
|
12
|
+
"Export complete",
|
|
13
|
+
body="Your report is ready to download.",
|
|
14
|
+
severity="success",
|
|
15
|
+
route="/exports/123",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
`route` resolves relative to the calling app (`/apps/{plugin_id}/exports/123`);
|
|
19
|
+
pass an absolute `/apps/...` route to target another app's window. By default
|
|
20
|
+
the notification goes to the user making the current request; pass `user_id`
|
|
21
|
+
to notify a different member of the same organisation (the platform rejects
|
|
22
|
+
targets outside the caller's org).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotificationsClient:
|
|
31
|
+
"""Thin wrapper around the platform-injected notification service.
|
|
32
|
+
|
|
33
|
+
In production/hosted sandbox the platform injects the service into
|
|
34
|
+
`PluginContext`. In local unit tests, pass a fake service with the same
|
|
35
|
+
async `push` method.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, service: Any = None):
|
|
39
|
+
self._service = service
|
|
40
|
+
|
|
41
|
+
def _require_service(self) -> Any:
|
|
42
|
+
if self._service is None:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
"Notification service is not available in this runtime. "
|
|
45
|
+
"Run inside Palette OS/hosted sandbox or inject a fake service in tests."
|
|
46
|
+
)
|
|
47
|
+
return self._service
|
|
48
|
+
|
|
49
|
+
async def push(
|
|
50
|
+
self,
|
|
51
|
+
title: str,
|
|
52
|
+
*,
|
|
53
|
+
body: str | None = None,
|
|
54
|
+
route: str | None = None,
|
|
55
|
+
severity: str | None = None,
|
|
56
|
+
data: dict[str, Any] | None = None,
|
|
57
|
+
user_id: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Push a notification; returns the serialized notification dict."""
|
|
60
|
+
return await self._require_service().push(
|
|
61
|
+
title=title,
|
|
62
|
+
body=body,
|
|
63
|
+
route=route,
|
|
64
|
+
severity=severity,
|
|
65
|
+
data=data,
|
|
66
|
+
user_id=user_id,
|
|
67
|
+
)
|
|
@@ -14,6 +14,7 @@ from palette_sdk.data_rooms import DataRoomsClient
|
|
|
14
14
|
from palette_sdk.connections import PluginConnectionsClient
|
|
15
15
|
from palette_sdk.apps import AppInteropClient
|
|
16
16
|
from palette_sdk.members import OrganizationMembersClient
|
|
17
|
+
from palette_sdk.notifications import NotificationsClient
|
|
17
18
|
from palette_sdk.platform_services import UnavailablePlatformService
|
|
18
19
|
from palette_sdk.events import EventPublisher
|
|
19
20
|
|
|
@@ -40,6 +41,7 @@ class PluginContext:
|
|
|
40
41
|
permissions: List of permissions declared in the manifest
|
|
41
42
|
storage: Storage service for file upload/download
|
|
42
43
|
members: Organization member helpers for the current org
|
|
44
|
+
notifications: OS notification center push helpers
|
|
43
45
|
"""
|
|
44
46
|
db: AsyncSession
|
|
45
47
|
user_id: str
|
|
@@ -52,6 +54,7 @@ class PluginContext:
|
|
|
52
54
|
connections: PluginConnectionsClient = field(default_factory=PluginConnectionsClient)
|
|
53
55
|
apps: AppInteropClient = field(default_factory=AppInteropClient)
|
|
54
56
|
members: OrganizationMembersClient = field(default_factory=OrganizationMembersClient)
|
|
57
|
+
notifications: NotificationsClient = field(default_factory=NotificationsClient)
|
|
55
58
|
redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
|
|
56
59
|
vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
|
|
57
60
|
events: EventPublisher = field(default_factory=EventPublisher)
|
|
@@ -128,6 +131,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
128
131
|
getattr(state, "org_members", None),
|
|
129
132
|
getattr(state, "plugin_permissions", []),
|
|
130
133
|
),
|
|
134
|
+
notifications=NotificationsClient(getattr(state, "notifications", None)),
|
|
131
135
|
redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
|
|
132
136
|
vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
|
|
133
137
|
events=EventPublisher(getattr(state, "plugin_events", None)),
|
|
@@ -114,6 +114,7 @@ Available context values:
|
|
|
114
114
|
| `ctx.data_rooms` | Backend Data Room client |
|
|
115
115
|
| `ctx.connections` | Palette-managed third-party connection client |
|
|
116
116
|
| `ctx.members` | Current organisation member client |
|
|
117
|
+
| `ctx.notifications` | Push OS notification-center notifications |
|
|
117
118
|
| `ctx.apps` / `services(ctx)` | Governed app-to-app calls through declared `consumes` contracts |
|
|
118
119
|
| `ctx.events` | Publish event topics declared in `provides.events` |
|
|
119
120
|
| `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
|
|
@@ -141,6 +142,7 @@ These are the public Python helpers exported by `palette_sdk`.
|
|
|
141
142
|
| `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
|
|
142
143
|
| `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
|
|
143
144
|
| `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
|
|
145
|
+
| `NotificationsClient`, `ctx.notifications` | Push notifications into the OS notification center |
|
|
144
146
|
| `PluginConnectionsClient`, `ConnectionStatus`, `MissingConnectionError`, `ctx.connections` | Third-party connection status and token helpers |
|
|
145
147
|
| `AppInteropClient`, `AppServiceClient`, `MissingAppServiceError`, `ctx.apps` | Call required apps/services without direct database access |
|
|
146
148
|
| `service(...)`, `services(ctx)`, `ServicesClient`, `BrokerCallError` | Provide and consume OS-broker service methods/events |
|
|
@@ -592,6 +594,50 @@ In local unit tests outside the Palette runtime, inject a fake Data Room service
|
|
|
592
594
|
if you need to test routes that call `ctx.data_rooms`. In hosted sandbox and
|
|
593
595
|
real OS runtime, the platform injects the real service.
|
|
594
596
|
|
|
597
|
+
## 8a. OS Notifications From Python
|
|
598
|
+
|
|
599
|
+
Backend code can push persistent notifications into the OS notification
|
|
600
|
+
center (the bell) — the Python counterpart of the frontend SDK's
|
|
601
|
+
`notifications.push()`:
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
from palette_sdk import PluginContext, get_plugin_context, require_permission
|
|
605
|
+
|
|
606
|
+
@router.post("/exports", dependencies=[require_permission("resources:write")])
|
|
607
|
+
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
608
|
+
...
|
|
609
|
+
await ctx.notifications.push(
|
|
610
|
+
"Export complete",
|
|
611
|
+
body="Your report is ready to download.",
|
|
612
|
+
severity="success", # "info" | "success" | "warning" | "error"
|
|
613
|
+
route="/exports/123", # opened inside your app on click
|
|
614
|
+
data={"export_id": 123}, # arbitrary payload stored with it
|
|
615
|
+
)
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The notification shows as a live toast plus a notification-center entry and
|
|
619
|
+
opens/focuses your app window at the resolved route when clicked. `route`
|
|
620
|
+
resolves relative to your app (`/apps/{plugin_id}/exports/123`); pass an
|
|
621
|
+
absolute `/apps/...` route to target another app's window. Omit `route` to
|
|
622
|
+
open your app's main window.
|
|
623
|
+
|
|
624
|
+
By default the notification targets the user making the current request. Pass
|
|
625
|
+
`user_id` to notify a different member of the same organisation (useful for
|
|
626
|
+
approval flows); targets outside the caller's organisation are rejected:
|
|
627
|
+
|
|
628
|
+
```python
|
|
629
|
+
await ctx.notifications.push(
|
|
630
|
+
"Approval needed",
|
|
631
|
+
body=f"{requester.name} requested a budget increase.",
|
|
632
|
+
route="/approvals",
|
|
633
|
+
user_id=str(approver_id),
|
|
634
|
+
)
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
During `pltt dev` notifications are not delivered to a real notification
|
|
638
|
+
center — the simulator logs them as `[palette-notification] {...}` and returns
|
|
639
|
+
a stub response, so your code paths still run.
|
|
640
|
+
|
|
595
641
|
## 9. Config And Secrets
|
|
596
642
|
|
|
597
643
|
Use config for app install settings and secrets for sensitive values.
|
package/lib/commands/publish.js
CHANGED
|
@@ -410,6 +410,7 @@ async function run(argv, { cwd }) {
|
|
|
410
410
|
log(`[pltt] ${backend.length} bytes`)
|
|
411
411
|
|
|
412
412
|
const backendSha = sha256(backend)
|
|
413
|
+
const frontendSha = frontend ? sha256(frontend) : null
|
|
413
414
|
const api = makeApi(env)
|
|
414
415
|
|
|
415
416
|
log("[pltt] requesting signed URLs")
|
|
@@ -419,6 +420,7 @@ async function run(argv, { cwd }) {
|
|
|
419
420
|
plugin_id: manifest.id,
|
|
420
421
|
version: manifest.version,
|
|
421
422
|
bundle_sha256: backendSha,
|
|
423
|
+
...(frontendSha ? { frontend_sha256: frontendSha } : {}),
|
|
422
424
|
publish_type: publishType,
|
|
423
425
|
},
|
|
424
426
|
})
|
package/lib/dev-simulator.js
CHANGED
|
@@ -178,6 +178,7 @@ import pathlib
|
|
|
178
178
|
import re
|
|
179
179
|
import sys
|
|
180
180
|
import uuid
|
|
181
|
+
from datetime import datetime, timezone
|
|
181
182
|
from types import SimpleNamespace
|
|
182
183
|
|
|
183
184
|
from fastapi import FastAPI, HTTPException, Request, Response
|
|
@@ -301,6 +302,47 @@ class LocalEventPublisher:
|
|
|
301
302
|
async def publish(self, topic: str, payload=None):
|
|
302
303
|
print("[palette-event]", topic, json.dumps(payload or {}, sort_keys=True))
|
|
303
304
|
|
|
305
|
+
class LocalNotificationsService:
|
|
306
|
+
"""Local stand-in for the platform notification pool: logs and returns a stub."""
|
|
307
|
+
|
|
308
|
+
def __init__(self):
|
|
309
|
+
self._next_id = 1
|
|
310
|
+
|
|
311
|
+
def _resolve_action_route(self, route, plugin_id):
|
|
312
|
+
if not route:
|
|
313
|
+
return f"/apps/{plugin_id}" if plugin_id else None
|
|
314
|
+
if "://" in route:
|
|
315
|
+
raise ValueError("route must be an internal route starting with '/'")
|
|
316
|
+
if route.startswith("/apps/"):
|
|
317
|
+
return route
|
|
318
|
+
suffix = route if route.startswith("/") else "/" + route
|
|
319
|
+
return f"/apps/{plugin_id}{suffix}" if plugin_id else suffix
|
|
320
|
+
|
|
321
|
+
async def push(self, *, title, body=None, route=None, severity=None, data=None, user_id=None):
|
|
322
|
+
if not title or not str(title).strip():
|
|
323
|
+
raise ValueError("notification title is required")
|
|
324
|
+
if severity is not None and severity not in ("info", "success", "warning", "error"):
|
|
325
|
+
raise ValueError("severity must be one of: info, success, warning, error")
|
|
326
|
+
plugin_id = MANIFEST.get("id", "")
|
|
327
|
+
notification = {
|
|
328
|
+
"id": self._next_id,
|
|
329
|
+
"organization_id": 1,
|
|
330
|
+
"type": "app",
|
|
331
|
+
"title": str(title),
|
|
332
|
+
"body": body,
|
|
333
|
+
"data_json": json.dumps(data) if data is not None else None,
|
|
334
|
+
"source_app_id": plugin_id or None,
|
|
335
|
+
"action_route": self._resolve_action_route(route, plugin_id),
|
|
336
|
+
"severity": severity,
|
|
337
|
+
"is_read": False,
|
|
338
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
339
|
+
}
|
|
340
|
+
self._next_id += 1
|
|
341
|
+
print("[palette-notification]", json.dumps(notification, sort_keys=True))
|
|
342
|
+
return notification
|
|
343
|
+
|
|
344
|
+
NOTIFICATIONS = LocalNotificationsService()
|
|
345
|
+
|
|
304
346
|
spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
|
|
305
347
|
module = importlib.util.module_from_spec(spec)
|
|
306
348
|
assert spec and spec.loader
|
|
@@ -341,6 +383,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
341
383
|
request.state.plugin_local_connections = DEV_CONNECTIONS
|
|
342
384
|
request.state.plugin_apps = LocalAppInteropService()
|
|
343
385
|
request.state.plugin_events = LocalEventPublisher()
|
|
386
|
+
request.state.notifications = NOTIFICATIONS
|
|
344
387
|
request.state.storage = DEV_STORAGE
|
|
345
388
|
if DEV_REDIS is not None:
|
|
346
389
|
request.state.redis = DEV_REDIS
|
package/lib/manifest.js
CHANGED
|
@@ -32,6 +32,8 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
32
32
|
"category",
|
|
33
33
|
"tagline",
|
|
34
34
|
"description",
|
|
35
|
+
"release_notes",
|
|
36
|
+
"changelog",
|
|
35
37
|
"icon",
|
|
36
38
|
"gradient",
|
|
37
39
|
"sdk",
|
|
@@ -526,6 +528,8 @@ function validateManifest(m) {
|
|
|
526
528
|
requireString(m, "category", "manifest", errors)
|
|
527
529
|
requireString(m, "tagline", "manifest", errors)
|
|
528
530
|
requireString(m, "description", "manifest", errors)
|
|
531
|
+
requireString(m, "release_notes", "manifest", errors)
|
|
532
|
+
requireString(m, "changelog", "manifest", errors)
|
|
529
533
|
requireString(m, "icon", "manifest", errors)
|
|
530
534
|
|
|
531
535
|
if (m.gradient !== undefined) {
|
package/package.json
CHANGED