@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 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)),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.8"
3
+ version = "0.1.9"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.
@@ -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
  })
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.54",
3
+ "version": "0.3.55",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@palettelab/sdk": "^0.1.24",
5
+ "@palettelab/sdk": "^0.1.25",
6
6
  "react": "^19.0.0",
7
7
  "react-dom": "^19.0.0"
8
8
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.24",
6
+ "@palettelab/sdk": "^0.1.25",
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.24", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.25", "react": "^19.0.0" }
6
6
  }
@@ -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.24", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.25", "react": "^19.0.0" }
6
6
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.24",
6
+ "@palettelab/sdk": "^0.1.25",
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.24",
6
+ "@palettelab/sdk": "^0.1.25",
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.24",
6
+ "@palettelab/sdk": "^0.1.25",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@palettelab/sdk": "^0.1.24",
5
+ "@palettelab/sdk": "^0.1.25",
6
6
  "react": "^19.0.0"
7
7
  }
8
8
  }