@palettelab/cli 0.3.33 → 0.3.35
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 +47 -1
- package/backend-sdk/palette_sdk/__init__.py +2 -0
- package/backend-sdk/palette_sdk/members.py +45 -0
- package/backend-sdk/palette_sdk/permissions.py +1 -0
- package/backend-sdk/palette_sdk/plugin_context.py +7 -0
- package/docs/python-backend-sdk.md +151 -1
- package/lib/manifest.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -275,10 +275,11 @@ 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.redis`, and `ctx.vector`.
|
|
278
|
+
- `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, `ctx.db`, `ctx.members`, `ctx.redis`, and `ctx.vector`.
|
|
279
279
|
- `ctx.db` is the full scoped SQLAlchemy `AsyncSession` for app-owned database data.
|
|
280
280
|
- `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
|
|
281
281
|
- `ctx.data_rooms` gives backend access to Palette Data Rooms without importing platform internals.
|
|
282
|
+
- `ctx.members` gives backend access to current organisation members; it exposes list/get/invite/update-role helpers, but no delete/remove helper.
|
|
282
283
|
- `ctx.has_permission("...")`, `ctx.has_any_permission([...])`, and `ctx.has_all_permissions([...])` check declared permissions.
|
|
283
284
|
- `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
|
|
284
285
|
- `ctx.secret("KEY")` reads app secrets from config or environment variables.
|
|
@@ -509,6 +510,51 @@ management. For advanced Qdrant features, use `ctx.vector.client()` with
|
|
|
509
510
|
`collection_name()`, `scoped_filter()`, `merge_filter()`, `scoped_payload()`,
|
|
510
511
|
and `scoped_point()` so custom calls remain scoped.
|
|
511
512
|
|
|
513
|
+
Common managed-service commands:
|
|
514
|
+
|
|
515
|
+
```python
|
|
516
|
+
# Redis strings, counters, and key discovery
|
|
517
|
+
await ctx.redis.get("key", default=None)
|
|
518
|
+
await ctx.redis.set("key", {"json": True}, ttl=600)
|
|
519
|
+
await ctx.redis.delete("key1", "key2")
|
|
520
|
+
await ctx.redis.exists("key")
|
|
521
|
+
await ctx.redis.expire("key", 300)
|
|
522
|
+
await ctx.redis.ttl("key")
|
|
523
|
+
await ctx.redis.incr("counter")
|
|
524
|
+
await ctx.redis.decr("counter")
|
|
525
|
+
await ctx.redis.scan(prefix="cache:", limit=100)
|
|
526
|
+
|
|
527
|
+
# Redis hashes, lists, sets, sorted sets, streams, locks
|
|
528
|
+
await ctx.redis.hset("hash", "field", {"value": 1})
|
|
529
|
+
await ctx.redis.hgetall("hash")
|
|
530
|
+
await ctx.redis.lpush("queue", {"job": 1})
|
|
531
|
+
await ctx.redis.lrange("queue", 0, -1)
|
|
532
|
+
await ctx.redis.sadd("tags", "red", "blue")
|
|
533
|
+
await ctx.redis.smembers("tags")
|
|
534
|
+
await ctx.redis.zadd("scores", {"alice": 10})
|
|
535
|
+
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)
|
|
538
|
+
await ctx.redis.lock("invoice:1", token, ttl=30)
|
|
539
|
+
await ctx.redis.unlock("invoice:1", token)
|
|
540
|
+
|
|
541
|
+
# Redis provider-style data-plane calls
|
|
542
|
+
await ctx.redis.execute("MSET", "a", "1", "b", "2")
|
|
543
|
+
values = await ctx.redis.execute("MGET", "a", "b")
|
|
544
|
+
|
|
545
|
+
# Vector search
|
|
546
|
+
await ctx.vector.upsert_texts("knowledge", [{"id": "doc-1", "text": "Text"}])
|
|
547
|
+
await ctx.vector.upsert_vectors("knowledge", [{"id": "vec-1", "vector": [0.1, 0.2]}])
|
|
548
|
+
hits = await ctx.vector.search("knowledge", query="invoice policy", top_k=10)
|
|
549
|
+
record = await ctx.vector.get("knowledge", "doc-1")
|
|
550
|
+
await ctx.vector.delete("knowledge", ["doc-1"])
|
|
551
|
+
await ctx.vector.delete_index("knowledge")
|
|
552
|
+
stats = await ctx.vector.stats("knowledge")
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Blocked Redis control-plane commands include `FLUSHDB`, `FLUSHALL`, `CONFIG`,
|
|
556
|
+
`KEYS`, `CLUSTER`, `SCRIPT`, `EVAL`, `FUNCTION`, and `SELECT`.
|
|
557
|
+
|
|
512
558
|
### `pltt login`
|
|
513
559
|
|
|
514
560
|
Save a Palette sandbox or production environment URL plus token in `~/.palette/config.json` with file mode `0600`. Environment variables still override the stored token when present.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from palette_sdk.plugin_router import PluginRouter
|
|
4
4
|
from palette_sdk.plugin_context import MissingSecretError, PluginContext, get_plugin_context
|
|
5
5
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
6
|
+
from palette_sdk.members import OrganizationMembersClient
|
|
6
7
|
from palette_sdk.platform_services import (
|
|
7
8
|
LocalRedisService,
|
|
8
9
|
LocalVectorService,
|
|
@@ -42,6 +43,7 @@ __all__ = [
|
|
|
42
43
|
"MissingSecretError",
|
|
43
44
|
"get_plugin_context",
|
|
44
45
|
"DataRoomsClient",
|
|
46
|
+
"OrganizationMembersClient",
|
|
45
47
|
"LocalRedisService",
|
|
46
48
|
"LocalVectorService",
|
|
47
49
|
"PlatformServiceUnavailable",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Backend organization member helpers for plugin Python code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OrganizationMembersClient:
|
|
9
|
+
"""Thin wrapper around the platform-injected organization member service."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, service: Any = None, permissions: list[str] | None = None):
|
|
12
|
+
self._service = service
|
|
13
|
+
self._permissions = permissions or []
|
|
14
|
+
|
|
15
|
+
def _require_service(self) -> Any:
|
|
16
|
+
if self._service is None:
|
|
17
|
+
raise RuntimeError(
|
|
18
|
+
"Organization member service is not available in this runtime. "
|
|
19
|
+
"Run inside Palette OS/hosted sandbox or inject a fake service in tests."
|
|
20
|
+
)
|
|
21
|
+
return self._service
|
|
22
|
+
|
|
23
|
+
def _require_permission(self, permission: str) -> None:
|
|
24
|
+
if permission not in self._permissions:
|
|
25
|
+
raise PermissionError(f"App does not declare required permission: {permission}")
|
|
26
|
+
|
|
27
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
28
|
+
self._require_permission("members:read")
|
|
29
|
+
return await self._require_service().list_members()
|
|
30
|
+
|
|
31
|
+
async def get(self, user_id: str) -> dict[str, Any] | None:
|
|
32
|
+
self._require_permission("members:read")
|
|
33
|
+
return await self._require_service().get_member(user_id)
|
|
34
|
+
|
|
35
|
+
async def get_by_email(self, email: str) -> dict[str, Any] | None:
|
|
36
|
+
self._require_permission("members:read")
|
|
37
|
+
return await self._require_service().get_member_by_email(email)
|
|
38
|
+
|
|
39
|
+
async def invite(self, email: str, role: str = "member") -> dict[str, Any]:
|
|
40
|
+
self._require_permission("members:write")
|
|
41
|
+
return await self._require_service().invite_member(email, role)
|
|
42
|
+
|
|
43
|
+
async def update_role(self, user_id: str, role: str) -> dict[str, Any]:
|
|
44
|
+
self._require_permission("members:write")
|
|
45
|
+
return await self._require_service().update_member_role(user_id, role)
|
|
@@ -11,6 +11,7 @@ from fastapi import Depends, Request
|
|
|
11
11
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
12
|
|
|
13
13
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
14
|
+
from palette_sdk.members import OrganizationMembersClient
|
|
14
15
|
from palette_sdk.platform_services import UnavailablePlatformService
|
|
15
16
|
|
|
16
17
|
|
|
@@ -35,6 +36,7 @@ class PluginContext:
|
|
|
35
36
|
plugin_id: The plugin's ID from its manifest
|
|
36
37
|
permissions: List of permissions declared in the manifest
|
|
37
38
|
storage: Storage service for file upload/download
|
|
39
|
+
members: Organization member helpers for the current org
|
|
38
40
|
"""
|
|
39
41
|
db: AsyncSession
|
|
40
42
|
user_id: str
|
|
@@ -44,6 +46,7 @@ class PluginContext:
|
|
|
44
46
|
permissions: list[str] = field(default_factory=list)
|
|
45
47
|
storage: Any = None # Platform storage service injected at runtime
|
|
46
48
|
data_rooms: DataRoomsClient = field(default_factory=DataRoomsClient)
|
|
49
|
+
members: OrganizationMembersClient = field(default_factory=OrganizationMembersClient)
|
|
47
50
|
redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
|
|
48
51
|
vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
|
|
49
52
|
config: dict[str, Any] = field(default_factory=dict)
|
|
@@ -110,6 +113,10 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
110
113
|
permissions=getattr(state, "plugin_permissions", []),
|
|
111
114
|
storage=getattr(state, "storage", None),
|
|
112
115
|
data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
|
|
116
|
+
members=OrganizationMembersClient(
|
|
117
|
+
getattr(state, "org_members", None),
|
|
118
|
+
getattr(state, "plugin_permissions", []),
|
|
119
|
+
),
|
|
113
120
|
redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
|
|
114
121
|
vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
|
|
115
122
|
config=getattr(state, "plugin_config", {}),
|
|
@@ -112,6 +112,7 @@ Available context values:
|
|
|
112
112
|
| `ctx.permissions` | Permissions granted from `palette-plugin.json` |
|
|
113
113
|
| `ctx.storage` | Runtime storage service, when available |
|
|
114
114
|
| `ctx.data_rooms` | Backend Data Room client |
|
|
115
|
+
| `ctx.members` | Current organisation member client |
|
|
115
116
|
| `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
|
|
116
117
|
| `ctx.vector` | Plugin/org-scoped vector service when `platform_services` includes `vector` |
|
|
117
118
|
| `ctx.config` | App install/config values |
|
|
@@ -167,10 +168,26 @@ data_rooms:read, data_rooms:write
|
|
|
167
168
|
agents:read, agents:write
|
|
168
169
|
chat:read, chat:write
|
|
169
170
|
routines:read, routines:write
|
|
170
|
-
members:read
|
|
171
|
+
members:read, members:write
|
|
171
172
|
resources:read, resources:write
|
|
172
173
|
```
|
|
173
174
|
|
|
175
|
+
Organisation member helpers:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
@router.get("/members", dependencies=[require_permission("members:read")])
|
|
179
|
+
async def members(ctx: PluginContext = Depends(get_plugin_context)):
|
|
180
|
+
return await ctx.members.list()
|
|
181
|
+
|
|
182
|
+
@router.post("/members/invite", dependencies=[require_permission("members:write")])
|
|
183
|
+
async def invite_member(email: str, ctx: PluginContext = Depends(get_plugin_context)):
|
|
184
|
+
return await ctx.members.invite(email, role="member")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`ctx.members` exposes `list()`, `get(user_id)`, `get_by_email(email)`,
|
|
188
|
+
`invite(email, role="member")`, and `update_role(user_id, role)`. Member
|
|
189
|
+
deletion/removal is intentionally not exposed through the app SDK.
|
|
190
|
+
|
|
174
191
|
## 6. App-Owned Data
|
|
175
192
|
|
|
176
193
|
Apps can ship their own tables and migrations. Use `OrgScopedTable` for data
|
|
@@ -344,6 +361,52 @@ Alembic migrations. Route code intentionally has data permissions only; it
|
|
|
344
361
|
cannot access Palette core tables, another app's schema, or another org's
|
|
345
362
|
RLS-protected rows.
|
|
346
363
|
|
|
364
|
+
Common `ctx.db` operations:
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
from sqlalchemy import delete, func, insert, select, text, update
|
|
368
|
+
|
|
369
|
+
rows = (await ctx.db.execute(select(Invoice))).scalars().all()
|
|
370
|
+
invoice = await ctx.db.scalar(select(Invoice).where(Invoice.id == invoice_id))
|
|
371
|
+
count = await ctx.db.scalar(select(func.count()).select_from(Invoice))
|
|
372
|
+
|
|
373
|
+
ctx.db.add(Invoice(customer_name="A", amount=10, status="draft"))
|
|
374
|
+
ctx.db.add_all([
|
|
375
|
+
Invoice(customer_name="B", amount=20, status="draft"),
|
|
376
|
+
Invoice(customer_name="C", amount=30, status="draft"),
|
|
377
|
+
])
|
|
378
|
+
await ctx.db.execute(insert(Invoice).values(customer_name="D", amount=40, status="draft"))
|
|
379
|
+
await ctx.db.execute(update(Invoice).where(Invoice.id == invoice_id).values(status="paid"))
|
|
380
|
+
await ctx.db.execute(delete(Invoice).where(Invoice.id == invoice_id))
|
|
381
|
+
|
|
382
|
+
async with ctx.db.begin():
|
|
383
|
+
ctx.db.add(Invoice(customer_name="E", amount=50, status="queued"))
|
|
384
|
+
|
|
385
|
+
await ctx.db.commit()
|
|
386
|
+
await ctx.db.rollback()
|
|
387
|
+
await ctx.db.refresh(invoice)
|
|
388
|
+
|
|
389
|
+
result = await ctx.db.execute(
|
|
390
|
+
text("select id, customer_name from finance_tools__invoices where amount > :amount"),
|
|
391
|
+
{"amount": 100},
|
|
392
|
+
)
|
|
393
|
+
rows = result.mappings().all()
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Common migration operations:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
op.create_table(...)
|
|
400
|
+
op.add_column(...)
|
|
401
|
+
op.alter_column(...)
|
|
402
|
+
op.create_index(...)
|
|
403
|
+
op.create_unique_constraint(...)
|
|
404
|
+
op.create_foreign_key(...)
|
|
405
|
+
op.drop_index(...)
|
|
406
|
+
op.drop_column(...)
|
|
407
|
+
ensure_org_rls(op, "finance_tools__invoices")
|
|
408
|
+
```
|
|
409
|
+
|
|
347
410
|
## 8. Data Rooms From Python
|
|
348
411
|
|
|
349
412
|
Use `ctx.data_rooms` when backend code needs to create folders, find files, or
|
|
@@ -569,6 +632,93 @@ while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
|
|
|
569
632
|
client for custom calls; combine it with `collection_name()`, `scoped_filter()`,
|
|
570
633
|
`merge_filter()`, `scoped_payload()`, and `scoped_point()` to preserve isolation.
|
|
571
634
|
|
|
635
|
+
Common `ctx.redis` commands:
|
|
636
|
+
|
|
637
|
+
```python
|
|
638
|
+
await ctx.redis.get("key", default=None)
|
|
639
|
+
await ctx.redis.set("key", {"json": True}, ttl=600, nx=False, xx=False)
|
|
640
|
+
await ctx.redis.delete("key1", "key2")
|
|
641
|
+
await ctx.redis.exists("key")
|
|
642
|
+
await ctx.redis.expire("key", 300)
|
|
643
|
+
await ctx.redis.ttl("key")
|
|
644
|
+
await ctx.redis.incr("counter", 1)
|
|
645
|
+
await ctx.redis.decr("counter", 1)
|
|
646
|
+
await ctx.redis.scan(prefix="cache:", limit=100)
|
|
647
|
+
|
|
648
|
+
await ctx.redis.hset("hash", "field", {"value": 1})
|
|
649
|
+
await ctx.redis.hget("hash", "field")
|
|
650
|
+
await ctx.redis.hgetall("hash")
|
|
651
|
+
await ctx.redis.hdel("hash", "field")
|
|
652
|
+
|
|
653
|
+
await ctx.redis.lpush("queue", {"job": 1})
|
|
654
|
+
await ctx.redis.rpush("queue", {"job": 2})
|
|
655
|
+
await ctx.redis.lpop("queue")
|
|
656
|
+
await ctx.redis.rpop("queue")
|
|
657
|
+
await ctx.redis.lrange("queue", 0, -1)
|
|
658
|
+
|
|
659
|
+
await ctx.redis.sadd("tags", "red", "blue")
|
|
660
|
+
await ctx.redis.smembers("tags")
|
|
661
|
+
await ctx.redis.srem("tags", "red")
|
|
662
|
+
|
|
663
|
+
await ctx.redis.zadd("scores", {"alice": 10, "bob": 8})
|
|
664
|
+
await ctx.redis.zrange("scores", 0, -1, with_scores=True)
|
|
665
|
+
await ctx.redis.zrem("scores", "bob")
|
|
666
|
+
|
|
667
|
+
await ctx.redis.enqueue("jobs", {"task": "sync"})
|
|
668
|
+
await ctx.redis.dequeue("jobs")
|
|
669
|
+
await ctx.redis.xadd("events", {"type": "created"})
|
|
670
|
+
await ctx.redis.xread({"events": "0-0"}, count=10)
|
|
671
|
+
await ctx.redis.lock("invoice:1", token, ttl=30)
|
|
672
|
+
await ctx.redis.unlock("invoice:1", token)
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Provider-style Redis commands:
|
|
676
|
+
|
|
677
|
+
```python
|
|
678
|
+
await ctx.redis.execute("MSET", "a", "1", "b", "2")
|
|
679
|
+
values = await ctx.redis.execute("MGET", "a", "b")
|
|
680
|
+
await ctx.redis.execute("ZUNIONSTORE", "dest", 2, "scores:1", "scores:2")
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Server/admin Redis commands are blocked because they would affect the shared
|
|
684
|
+
central Redis instance: `FLUSHDB`, `FLUSHALL`, `CONFIG`, `KEYS`, `CLUSTER`,
|
|
685
|
+
`SCRIPT`, `EVAL`, `FUNCTION`, `SELECT`, and similar control-plane commands.
|
|
686
|
+
|
|
687
|
+
Common `ctx.vector` commands:
|
|
688
|
+
|
|
689
|
+
```python
|
|
690
|
+
await ctx.vector.upsert_texts(
|
|
691
|
+
"knowledge",
|
|
692
|
+
[{"id": "doc-1", "text": "Text to embed", "metadata": {"type": "note"}}],
|
|
693
|
+
)
|
|
694
|
+
await ctx.vector.upsert_vectors(
|
|
695
|
+
"knowledge",
|
|
696
|
+
[{"id": "vec-1", "vector": [0.1, 0.2], "metadata": {"type": "manual"}}],
|
|
697
|
+
)
|
|
698
|
+
hits = await ctx.vector.search("knowledge", query="invoice policy", top_k=10)
|
|
699
|
+
hits = await ctx.vector.search("knowledge", vector=[0.1, 0.2], filter={"type": "note"})
|
|
700
|
+
record = await ctx.vector.get("knowledge", "doc-1")
|
|
701
|
+
await ctx.vector.delete("knowledge", ["doc-1"])
|
|
702
|
+
await ctx.vector.delete_index("knowledge")
|
|
703
|
+
stats = await ctx.vector.stats("knowledge")
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Advanced Qdrant calls should use scoped helpers:
|
|
707
|
+
|
|
708
|
+
```python
|
|
709
|
+
client = await ctx.vector.client()
|
|
710
|
+
collection = ctx.vector.collection_name()
|
|
711
|
+
query_filter = ctx.vector.scoped_filter("knowledge", {"type": "note"})
|
|
712
|
+
point = ctx.vector.scoped_point(
|
|
713
|
+
"knowledge",
|
|
714
|
+
id="doc-2",
|
|
715
|
+
vector=[0.1, 0.2],
|
|
716
|
+
text="Indexed text",
|
|
717
|
+
metadata={"type": "note"},
|
|
718
|
+
)
|
|
719
|
+
await client.upsert(collection_name=collection, points=[point])
|
|
720
|
+
```
|
|
721
|
+
|
|
572
722
|
## 10. Lifecycle Hooks
|
|
573
723
|
|
|
574
724
|
Lifecycle hooks let an app seed defaults or clean up app-owned data when the
|
package/lib/manifest.js
CHANGED