@palettelab/cli 0.3.25 → 0.3.27
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 +21 -3
- package/backend-sdk/palette_sdk/__init__.py +1 -1
- package/backend-sdk/palette_sdk/data_rooms.py +17 -0
- package/backend-sdk/palette_sdk/db/alembic_env.py +4 -2
- package/backend-sdk/palette_sdk/plugin_context.py +6 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +641 -0
- package/lib/commands/build.js +4 -0
- package/lib/dev-simulator.js +30 -1
- package/package.json +2 -1
- package/template-fallback/frontend/src/index.tsx +35 -23
- package/template-fallback/frontend/src/translations.ts +36 -0
- package/template-fallback/package.json +1 -1
- package/template-fallback/palette-plugin.json +1 -1
- package/template-fallback/templates/dashboard/frontend/src/index.tsx +5 -3
- package/template-fallback/templates/dashboard/frontend/src/translations.ts +12 -0
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
- package/template-fallback/templates/database/frontend/src/index.tsx +13 -12
- package/template-fallback/templates/database/frontend/src/translations.ts +30 -0
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/database/palette-plugin.json +1 -1
- package/template-fallback/templates/external-service/frontend/src/index.tsx +5 -3
- package/template-fallback/templates/external-service/frontend/src/translations.ts +12 -0
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/external-service/palette-plugin.json +1 -1
- package/template-fallback/templates/frontend-only/frontend/src/index.tsx +6 -3
- package/template-fallback/templates/frontend-only/frontend/src/translations.ts +12 -0
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# Palette Python Backend SDK Developer Guide
|
|
2
|
+
|
|
3
|
+
This guide is for developers building Palette apps with a Python/FastAPI
|
|
4
|
+
backend. Frontend code uses the npm package `@palettelab/sdk`; Python backend
|
|
5
|
+
code uses `palette_sdk`, which is embedded in `@palettelab/cli` for local dev
|
|
6
|
+
and provided by the Palette runtime in hosted sandbox and production.
|
|
7
|
+
|
|
8
|
+
## 1. Where The Backend Runs
|
|
9
|
+
|
|
10
|
+
Palette supports three backend development modes:
|
|
11
|
+
|
|
12
|
+
| Mode | Command | Uses real Palette OS services | Docker |
|
|
13
|
+
|---|---|---:|---:|
|
|
14
|
+
| Local SDK simulator | `pltt dev` | No | No |
|
|
15
|
+
| Hosted sandbox preview | `pltt dev --sandbox --env staging` | Yes | No |
|
|
16
|
+
| Internal local platform parity | `pltt dev --platform` | Local platform container | Yes |
|
|
17
|
+
|
|
18
|
+
Use `pltt dev` for the fastest loop while writing code. Use hosted sandbox
|
|
19
|
+
when the app must use real OS features such as login, organization context,
|
|
20
|
+
Data Rooms, storage, install state, review previews, logs, and platform APIs.
|
|
21
|
+
|
|
22
|
+
## 2. Generated App Structure
|
|
23
|
+
|
|
24
|
+
A Python backend app normally looks like this:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
my-app/
|
|
28
|
+
palette-plugin.json
|
|
29
|
+
frontend/
|
|
30
|
+
src/index.tsx
|
|
31
|
+
backend/
|
|
32
|
+
api/
|
|
33
|
+
main.py
|
|
34
|
+
models.py
|
|
35
|
+
migrations/
|
|
36
|
+
env.py
|
|
37
|
+
versions/
|
|
38
|
+
001_init.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The backend entry is declared in `palette-plugin.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"id": "finance-tools",
|
|
46
|
+
"name": "Finance Tools",
|
|
47
|
+
"frontend": { "entry": "./frontend/src/index.tsx" },
|
|
48
|
+
"backend": { "entry": "./backend/api/main.py" },
|
|
49
|
+
"permissions": [
|
|
50
|
+
"resources:read",
|
|
51
|
+
"resources:write",
|
|
52
|
+
"data_rooms:read",
|
|
53
|
+
"data_rooms:write"
|
|
54
|
+
],
|
|
55
|
+
"capabilities": { "database": true },
|
|
56
|
+
"database": {
|
|
57
|
+
"schema": "app_finance_tools",
|
|
58
|
+
"migrations": "./backend/migrations"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Declare only the permissions your app actually needs. Backend routes should
|
|
64
|
+
also gate access with `require_permission(...)`.
|
|
65
|
+
|
|
66
|
+
## 3. Basic Backend Route
|
|
67
|
+
|
|
68
|
+
Every backend file exports a `PluginRouter`. Routes are mounted by the platform
|
|
69
|
+
under `/api/v1/plugins/<plugin-id>`.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi import Depends
|
|
73
|
+
from palette_sdk import PluginRouter, PluginContext, get_plugin_context
|
|
74
|
+
|
|
75
|
+
router = PluginRouter(tags=["finance-tools"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/me")
|
|
79
|
+
async def me(ctx: PluginContext = Depends(get_plugin_context)):
|
|
80
|
+
return {
|
|
81
|
+
"user_id": ctx.user_id,
|
|
82
|
+
"organization_id": ctx.organization_id,
|
|
83
|
+
"org_role": ctx.org_role,
|
|
84
|
+
"plugin_id": ctx.plugin_id,
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If the app id is `finance-tools`, this route is available at:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
GET /api/v1/plugins/finance-tools/me
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 4. PluginContext Reference
|
|
95
|
+
|
|
96
|
+
`PluginContext` is injected into each route:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
async def route(ctx: PluginContext = Depends(get_plugin_context)):
|
|
100
|
+
...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Available context values:
|
|
104
|
+
|
|
105
|
+
| Field/helper | Purpose |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `ctx.db` | Async SQLAlchemy session for the app backend |
|
|
108
|
+
| `ctx.user_id` | Authenticated Palette user id |
|
|
109
|
+
| `ctx.organization_id` | Current organization id |
|
|
110
|
+
| `ctx.org_role` | User role in the current organization |
|
|
111
|
+
| `ctx.plugin_id` | Current app/plugin id |
|
|
112
|
+
| `ctx.permissions` | Permissions granted from `palette-plugin.json` |
|
|
113
|
+
| `ctx.storage` | Runtime storage service, when available |
|
|
114
|
+
| `ctx.data_rooms` | Backend Data Room client |
|
|
115
|
+
| `ctx.config` | App install/config values |
|
|
116
|
+
| `ctx.logger` | Runtime logger for backend code |
|
|
117
|
+
| `ctx.repo(Model)` | Org-safe CRUD helper for app-owned tables |
|
|
118
|
+
| `ctx.has_permission(permission)` | Check one declared permission |
|
|
119
|
+
| `ctx.has_any_permission([...])` | Check at least one permission |
|
|
120
|
+
| `ctx.has_all_permissions([...])` | Check every listed permission |
|
|
121
|
+
| `ctx.config_value(key, default)` | Read optional config |
|
|
122
|
+
| `ctx.require_config(key)` | Read required config or raise |
|
|
123
|
+
| `ctx.secret(key, default)` | Read a secret from app config or environment |
|
|
124
|
+
|
|
125
|
+
## 5. Permissions
|
|
126
|
+
|
|
127
|
+
Use route-level permission guards for normal APIs:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from fastapi import Depends
|
|
131
|
+
from palette_sdk import (
|
|
132
|
+
PluginRouter,
|
|
133
|
+
PluginContext,
|
|
134
|
+
get_plugin_context,
|
|
135
|
+
require_permission,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
router = PluginRouter(tags=["finance-tools"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/reports", dependencies=[require_permission("resources:read")])
|
|
142
|
+
async def reports(ctx: PluginContext = Depends(get_plugin_context)):
|
|
143
|
+
return {"items": []}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use inline checks only when the route supports multiple behavior paths:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
@router.get("/dashboard", dependencies=[require_permission("resources:read")])
|
|
150
|
+
async def dashboard(ctx: PluginContext = Depends(get_plugin_context)):
|
|
151
|
+
can_write = ctx.has_permission("resources:write")
|
|
152
|
+
can_sync = ctx.has_all_permissions(["data_rooms:read", "data_rooms:write"])
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"can_write": can_write,
|
|
156
|
+
"can_sync": can_sync,
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Known permissions currently include:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
tasks:read, tasks:write
|
|
164
|
+
data_rooms:read, data_rooms:write
|
|
165
|
+
agents:read, agents:write
|
|
166
|
+
chat:read, chat:write
|
|
167
|
+
routines:read, routines:write
|
|
168
|
+
members:read
|
|
169
|
+
resources:read, resources:write
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## 6. App-Owned Data
|
|
173
|
+
|
|
174
|
+
Apps can ship their own tables and migrations. Use `OrgScopedTable` for data
|
|
175
|
+
that belongs to an organization.
|
|
176
|
+
|
|
177
|
+
`backend/api/models.py`:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from sqlalchemy import Numeric, String
|
|
181
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
182
|
+
from palette_sdk import OrgScopedTable
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Invoice(OrgScopedTable):
|
|
186
|
+
__tablename__ = "invoices"
|
|
187
|
+
|
|
188
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
189
|
+
customer_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
190
|
+
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
|
191
|
+
status: Mapped[str] = mapped_column(String(32), default="draft")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`OrgScopedTable` adds `organization_id` automatically. Do not manually set
|
|
195
|
+
`organization_id` in route code; `ctx.repo(Model)` handles it.
|
|
196
|
+
|
|
197
|
+
### Migration Example
|
|
198
|
+
|
|
199
|
+
`backend/migrations/versions/001_init.py`:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from alembic import op
|
|
203
|
+
import sqlalchemy as sa
|
|
204
|
+
from palette_sdk.db import ensure_org_rls
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
revision = "001_init"
|
|
208
|
+
down_revision = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def upgrade():
|
|
212
|
+
op.create_table(
|
|
213
|
+
"invoices",
|
|
214
|
+
sa.Column("id", sa.Integer(), primary_key=True),
|
|
215
|
+
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
216
|
+
sa.Column("customer_name", sa.String(length=255), nullable=False),
|
|
217
|
+
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
|
218
|
+
sa.Column("status", sa.String(length=32), nullable=False),
|
|
219
|
+
)
|
|
220
|
+
op.create_index("ix_invoices_org", "invoices", ["organization_id"])
|
|
221
|
+
ensure_org_rls(op, "invoices")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def downgrade():
|
|
225
|
+
op.drop_table("invoices")
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`ensure_org_rls(op, "invoices")` enables row-level security so each
|
|
229
|
+
organization can only access its own rows.
|
|
230
|
+
|
|
231
|
+
## 7. Repository CRUD
|
|
232
|
+
|
|
233
|
+
Use `ctx.repo(Model)` instead of writing repeated org filters.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from fastapi import Depends, HTTPException
|
|
237
|
+
from pydantic import BaseModel
|
|
238
|
+
from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
|
|
239
|
+
from models import Invoice
|
|
240
|
+
|
|
241
|
+
router = PluginRouter(tags=["finance-tools"])
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class InvoiceIn(BaseModel):
|
|
245
|
+
customer_name: str
|
|
246
|
+
amount: float
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@router.get("/invoices", dependencies=[require_permission("resources:read")])
|
|
250
|
+
async def list_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
251
|
+
rows = await ctx.repo(Invoice).list(order_by="-id", limit=100)
|
|
252
|
+
return [
|
|
253
|
+
{
|
|
254
|
+
"id": row.id,
|
|
255
|
+
"customer_name": row.customer_name,
|
|
256
|
+
"amount": float(row.amount),
|
|
257
|
+
"status": row.status,
|
|
258
|
+
}
|
|
259
|
+
for row in rows
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.post("/invoices", dependencies=[require_permission("resources:write")])
|
|
264
|
+
async def create_invoice(
|
|
265
|
+
body: InvoiceIn,
|
|
266
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
267
|
+
):
|
|
268
|
+
invoice = await ctx.repo(Invoice).create(
|
|
269
|
+
customer_name=body.customer_name,
|
|
270
|
+
amount=body.amount,
|
|
271
|
+
)
|
|
272
|
+
return {"id": invoice.id}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@router.patch("/invoices/{invoice_id}", dependencies=[require_permission("resources:write")])
|
|
276
|
+
async def update_invoice(
|
|
277
|
+
invoice_id: int,
|
|
278
|
+
body: InvoiceIn,
|
|
279
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
280
|
+
):
|
|
281
|
+
invoice = await ctx.repo(Invoice).update(invoice_id, **body.model_dump())
|
|
282
|
+
if invoice is None:
|
|
283
|
+
raise HTTPException(status_code=404, detail="invoice not found")
|
|
284
|
+
return {"id": invoice.id}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.delete("/invoices/{invoice_id}", dependencies=[require_permission("resources:write")])
|
|
288
|
+
async def delete_invoice(
|
|
289
|
+
invoice_id: int,
|
|
290
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
291
|
+
):
|
|
292
|
+
deleted = await ctx.repo(Invoice).delete(invoice_id)
|
|
293
|
+
if not deleted:
|
|
294
|
+
raise HTTPException(status_code=404, detail="invoice not found")
|
|
295
|
+
return {"deleted": True}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Repository methods:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
await ctx.repo(Model).list(filters=None, order_by=None, limit=100, offset=0)
|
|
302
|
+
await ctx.repo(Model).count(filters=None)
|
|
303
|
+
await ctx.repo(Model).get(id)
|
|
304
|
+
await ctx.repo(Model).require(id)
|
|
305
|
+
await ctx.repo(Model).create(**values)
|
|
306
|
+
await ctx.repo(Model).update(id, **values)
|
|
307
|
+
await ctx.repo(Model).delete(id)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## 8. Data Rooms From Python
|
|
311
|
+
|
|
312
|
+
Use `ctx.data_rooms` when backend code needs to create folders, find files, or
|
|
313
|
+
read/write documents in Palette Data Rooms.
|
|
314
|
+
|
|
315
|
+
### Create Or Find A Room
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
@router.post("/setup-data-room", dependencies=[require_permission("data_rooms:write")])
|
|
319
|
+
async def setup_data_room(ctx: PluginContext = Depends(get_plugin_context)):
|
|
320
|
+
room = await ctx.data_rooms.ensure_room(
|
|
321
|
+
"Finance",
|
|
322
|
+
description="Finance documents used by the Finance Tools app",
|
|
323
|
+
)
|
|
324
|
+
return room
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Create Custom Folders By Name
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
@router.post("/setup-folders", dependencies=[require_permission("data_rooms:write")])
|
|
331
|
+
async def setup_folders(ctx: PluginContext = Depends(get_plugin_context)):
|
|
332
|
+
room = await ctx.data_rooms.ensure_room("Finance")
|
|
333
|
+
|
|
334
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
335
|
+
room["id"],
|
|
336
|
+
"Clients/Acme/Invoices/2026",
|
|
337
|
+
create=True,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"room_id": room["id"],
|
|
342
|
+
"folder_id": folder["id"],
|
|
343
|
+
"folder_name": folder["name"],
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`resolve_folder_path(..., create=True)` creates missing folders in the path.
|
|
348
|
+
Pass a list instead of a string when folder names can contain `/`:
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
352
|
+
room["id"],
|
|
353
|
+
["Clients", "Acme/International", "Invoices"],
|
|
354
|
+
create=True,
|
|
355
|
+
)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Upload A File
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
@router.post("/write-summary", dependencies=[require_permission("data_rooms:write")])
|
|
362
|
+
async def write_summary(ctx: PluginContext = Depends(get_plugin_context)):
|
|
363
|
+
room = await ctx.data_rooms.ensure_room("Finance")
|
|
364
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
365
|
+
room["id"],
|
|
366
|
+
"Reports/Monthly",
|
|
367
|
+
create=True,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
file = await ctx.data_rooms.upload_file(
|
|
371
|
+
room["id"],
|
|
372
|
+
"summary.txt",
|
|
373
|
+
b"Generated from Python backend",
|
|
374
|
+
folder_id=folder["id"],
|
|
375
|
+
content_type="text/plain",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return file
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Find And Read A File
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
@router.get("/read-summary", dependencies=[require_permission("data_rooms:read")])
|
|
385
|
+
async def read_summary(ctx: PluginContext = Depends(get_plugin_context)):
|
|
386
|
+
room = await ctx.data_rooms.require_room_by_name("Finance")
|
|
387
|
+
folder = await ctx.data_rooms.resolve_folder_path(room["id"], "Reports/Monthly")
|
|
388
|
+
|
|
389
|
+
if folder is None:
|
|
390
|
+
return {"found": False}
|
|
391
|
+
|
|
392
|
+
file = await ctx.data_rooms.find_file_by_name(
|
|
393
|
+
room["id"],
|
|
394
|
+
"summary.txt",
|
|
395
|
+
folder_id=folder["id"],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if file is None:
|
|
399
|
+
return {"found": False}
|
|
400
|
+
|
|
401
|
+
content = await ctx.data_rooms.read_file_bytes(file["id"])
|
|
402
|
+
return {
|
|
403
|
+
"found": True,
|
|
404
|
+
"text": content.decode("utf-8"),
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### List Room Contents
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
@router.get("/room-contents", dependencies=[require_permission("data_rooms:read")])
|
|
412
|
+
async def room_contents(ctx: PluginContext = Depends(get_plugin_context)):
|
|
413
|
+
room = await ctx.data_rooms.require_room_by_name("Finance")
|
|
414
|
+
contents = await ctx.data_rooms.contents(room["id"])
|
|
415
|
+
return contents
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Available Data Room helpers:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
await ctx.data_rooms.list()
|
|
422
|
+
await ctx.data_rooms.create(name, description=None)
|
|
423
|
+
await ctx.data_rooms.find_room_by_name(name, case_sensitive=False)
|
|
424
|
+
await ctx.data_rooms.require_room_by_name(name, case_sensitive=False)
|
|
425
|
+
await ctx.data_rooms.ensure_room(name, description=None)
|
|
426
|
+
await ctx.data_rooms.contents(room_id, folder_id=None)
|
|
427
|
+
await ctx.data_rooms.create_folder(room_id, name, parent_folder_id=None)
|
|
428
|
+
await ctx.data_rooms.find_folder_by_name(
|
|
429
|
+
room_id,
|
|
430
|
+
name,
|
|
431
|
+
parent_folder_id=None,
|
|
432
|
+
case_sensitive=False,
|
|
433
|
+
)
|
|
434
|
+
await ctx.data_rooms.ensure_folder(room_id, name, parent_folder_id=None)
|
|
435
|
+
await ctx.data_rooms.resolve_folder_path(
|
|
436
|
+
room_id,
|
|
437
|
+
"A/B/C",
|
|
438
|
+
create=False,
|
|
439
|
+
case_sensitive=False,
|
|
440
|
+
)
|
|
441
|
+
await ctx.data_rooms.find_file_by_name(
|
|
442
|
+
room_id,
|
|
443
|
+
name,
|
|
444
|
+
folder_id=None,
|
|
445
|
+
case_sensitive=False,
|
|
446
|
+
)
|
|
447
|
+
await ctx.data_rooms.read_file_bytes(file_id)
|
|
448
|
+
await ctx.data_rooms.upload_file(
|
|
449
|
+
room_id,
|
|
450
|
+
filename,
|
|
451
|
+
content,
|
|
452
|
+
folder_id=None,
|
|
453
|
+
content_type=None,
|
|
454
|
+
)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
In local unit tests outside the Palette runtime, inject a fake Data Room service
|
|
458
|
+
if you need to test routes that call `ctx.data_rooms`. In hosted sandbox and
|
|
459
|
+
real OS runtime, the platform injects the real service.
|
|
460
|
+
|
|
461
|
+
## 9. Config And Secrets
|
|
462
|
+
|
|
463
|
+
Use config for app install settings and secrets for sensitive values.
|
|
464
|
+
|
|
465
|
+
```python
|
|
466
|
+
@router.get("/sync-config", dependencies=[require_permission("resources:read")])
|
|
467
|
+
async def sync_config(ctx: PluginContext = Depends(get_plugin_context)):
|
|
468
|
+
timezone = ctx.config_value("timezone", "UTC")
|
|
469
|
+
required_workspace = ctx.require_config("workspace_id")
|
|
470
|
+
api_key = ctx.secret("FINANCE_API_KEY")
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
"timezone": timezone,
|
|
474
|
+
"workspace_id": required_workspace,
|
|
475
|
+
"has_api_key": bool(api_key),
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
`ctx.secret("KEY")` first checks app config secrets and then falls back to the
|
|
480
|
+
process environment variable named `KEY`.
|
|
481
|
+
|
|
482
|
+
## 10. Lifecycle Hooks
|
|
483
|
+
|
|
484
|
+
Lifecycle hooks let an app seed defaults or clean up app-owned data when the
|
|
485
|
+
platform installs, updates, enables, disables, or uninstalls the app.
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
from palette_sdk import LifecycleHooks, PluginContext
|
|
489
|
+
from models import DefaultSetting
|
|
490
|
+
|
|
491
|
+
lifecycle = LifecycleHooks()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@lifecycle.on_install
|
|
495
|
+
async def seed_defaults(ctx: PluginContext):
|
|
496
|
+
await ctx.repo(DefaultSetting).create(name="currency", value="USD")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@lifecycle.on_enable
|
|
500
|
+
async def on_enable(ctx: PluginContext):
|
|
501
|
+
ctx.logger.info("finance tools enabled for org %s", ctx.organization_id)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## 11. Calling Backend APIs From Frontend
|
|
505
|
+
|
|
506
|
+
Frontend code should call backend routes through the platform API helper, not by
|
|
507
|
+
hardcoding backend origins.
|
|
508
|
+
|
|
509
|
+
```tsx
|
|
510
|
+
import { createPaletteClient } from "@palettelab/sdk"
|
|
511
|
+
|
|
512
|
+
const palette = createPaletteClient()
|
|
513
|
+
|
|
514
|
+
async function loadInvoices() {
|
|
515
|
+
const res = await palette.apiFetch("/api/v1/plugins/finance-tools/invoices")
|
|
516
|
+
return res.json()
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
During `pltt dev`, the simulator routes this to the local backend. In hosted
|
|
521
|
+
sandbox or OS runtime, it goes through the real platform shell and auth context.
|
|
522
|
+
|
|
523
|
+
## 12. Local Development
|
|
524
|
+
|
|
525
|
+
Create and run an app:
|
|
526
|
+
|
|
527
|
+
```bash
|
|
528
|
+
npx --yes @palettelab/cli@latest init finance-tools --template database
|
|
529
|
+
cd finance-tools
|
|
530
|
+
npm install
|
|
531
|
+
npx --yes @palettelab/cli@latest dev
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Useful local checks:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
npx --yes @palettelab/cli@latest test
|
|
538
|
+
npx --yes @palettelab/cli@latest package
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
The CLI checks manifest shape, SDK compatibility, frontend bundling, backend
|
|
542
|
+
imports, backend route permission gates, declared permissions, migration safety,
|
|
543
|
+
package dependency policy, and backend package size.
|
|
544
|
+
|
|
545
|
+
## 13. Test In Real Palette OS Without Docker
|
|
546
|
+
|
|
547
|
+
Configure the hosted sandbox once:
|
|
548
|
+
|
|
549
|
+
```bash
|
|
550
|
+
npx --yes @palettelab/cli@latest login \
|
|
551
|
+
--env staging \
|
|
552
|
+
--url https://YOUR-PALETTE-STAGING-URL \
|
|
553
|
+
--token pltt_xxxxx
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Then publish a sandbox preview:
|
|
557
|
+
|
|
558
|
+
```bash
|
|
559
|
+
npx --yes @palettelab/cli@latest dev --sandbox --env staging
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The CLI packages the app, uploads it to the staging Palette environment,
|
|
563
|
+
creates a preview publish, and prints a real OS preview URL. Open that URL to
|
|
564
|
+
test the app with OS shell, auth context, organization context, Data Rooms,
|
|
565
|
+
storage, logs, install/review behavior, and platform APIs.
|
|
566
|
+
|
|
567
|
+
For internal test sandboxes, the backend environment can set:
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS=true
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
That lets passing preview publishes become active without manual approval.
|
|
574
|
+
|
|
575
|
+
## 14. Common Mistakes
|
|
576
|
+
|
|
577
|
+
- Do not import `@palettelab/sdk` from Python. That package is for frontend
|
|
578
|
+
JavaScript/React code only.
|
|
579
|
+
- Do not hardcode backend URLs in frontend code. Use the platform API helper.
|
|
580
|
+
- Do not manually set `organization_id` when using `ctx.repo(Model).create`.
|
|
581
|
+
- Do not query cross-org data. The platform runtime and RLS are designed around
|
|
582
|
+
the current organization context.
|
|
583
|
+
- Do not skip `require_permission(...)` on backend routes unless the route is
|
|
584
|
+
intentionally configured as public in the manifest.
|
|
585
|
+
- Do not commit publish tokens, app secrets, or generated `.palette/` local
|
|
586
|
+
state.
|
|
587
|
+
- Use hosted sandbox when you need real Data Rooms, login, organization,
|
|
588
|
+
install, logs, and OS behavior without Docker.
|
|
589
|
+
|
|
590
|
+
## 15. Complete Minimal Example
|
|
591
|
+
|
|
592
|
+
`backend/api/main.py`:
|
|
593
|
+
|
|
594
|
+
```python
|
|
595
|
+
from fastapi import Depends, HTTPException
|
|
596
|
+
from pydantic import BaseModel
|
|
597
|
+
from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
|
|
598
|
+
from models import Note
|
|
599
|
+
|
|
600
|
+
router = PluginRouter(tags=["notes-app"])
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class NoteIn(BaseModel):
|
|
604
|
+
body: str
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@router.get("/notes", dependencies=[require_permission("resources:read")])
|
|
608
|
+
async def list_notes(ctx: PluginContext = Depends(get_plugin_context)):
|
|
609
|
+
rows = await ctx.repo(Note).list(order_by="-id")
|
|
610
|
+
return [{"id": row.id, "body": row.body} for row in rows]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@router.post("/notes", dependencies=[require_permission("resources:write")])
|
|
614
|
+
async def create_note(
|
|
615
|
+
body: NoteIn,
|
|
616
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
617
|
+
):
|
|
618
|
+
if not body.body.strip():
|
|
619
|
+
raise HTTPException(status_code=400, detail="body required")
|
|
620
|
+
|
|
621
|
+
note = await ctx.repo(Note).create(body=body.body)
|
|
622
|
+
|
|
623
|
+
room = await ctx.data_rooms.ensure_room("Notes")
|
|
624
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
625
|
+
room["id"],
|
|
626
|
+
"Created From Backend",
|
|
627
|
+
create=True,
|
|
628
|
+
)
|
|
629
|
+
await ctx.data_rooms.upload_file(
|
|
630
|
+
room["id"],
|
|
631
|
+
f"note-{note.id}.txt",
|
|
632
|
+
note.body.encode("utf-8"),
|
|
633
|
+
folder_id=folder["id"],
|
|
634
|
+
content_type="text/plain",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
return {"id": note.id, "body": note.body}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
This route stores app-owned data in the app database and writes a related file
|
|
641
|
+
into Palette Data Rooms from the Python backend.
|
package/lib/commands/build.js
CHANGED
|
@@ -13,6 +13,10 @@ const BANNED_PATTERNS = [
|
|
|
13
13
|
{ re: /\bpublic\./, reason: "plugin migrations must not reference the platform's public schema" },
|
|
14
14
|
{ re: /\bSET\s+ROLE\b/i, reason: "SET ROLE in migrations escalates out of the plugin sandbox" },
|
|
15
15
|
{ re: /\bALTER\s+ROLE\b/i, reason: "ALTER ROLE is reserved for the platform" },
|
|
16
|
+
{ re: /\bGRANT\b/i, reason: "GRANT is reserved for the platform" },
|
|
17
|
+
{ re: /\bCREATE\s+EXTENSION\b/i, reason: "CREATE EXTENSION is reserved for the platform" },
|
|
18
|
+
{ re: /\bALTER\s+DATABASE\b/i, reason: "ALTER DATABASE is not allowed in plugin migrations" },
|
|
19
|
+
{ re: /\bDROP\s+SCHEMA\b/i, reason: "DROP SCHEMA is not allowed in plugin migrations" },
|
|
16
20
|
]
|
|
17
21
|
|
|
18
22
|
function lintMigrationFile(absPath) {
|
package/lib/dev-simulator.js
CHANGED
|
@@ -229,7 +229,7 @@ async function apiFetch(path, init) {
|
|
|
229
229
|
return fetch(target, init)
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
const
|
|
232
|
+
const basePlatform = {
|
|
233
233
|
user: {
|
|
234
234
|
id: "dev-user",
|
|
235
235
|
email: "developer@palette.local",
|
|
@@ -263,6 +263,11 @@ const platform = {
|
|
|
263
263
|
},
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
function normalizePaletteLanguage(language, fallback = "en") {
|
|
267
|
+
const value = String(language || "").trim().toLowerCase()
|
|
268
|
+
return value ? value.split("-")[0] : fallback
|
|
269
|
+
}
|
|
270
|
+
|
|
266
271
|
function Toasts() {
|
|
267
272
|
const [items, setItems] = React.useState([])
|
|
268
273
|
React.useEffect(() => {
|
|
@@ -280,6 +285,30 @@ function Toasts() {
|
|
|
280
285
|
}
|
|
281
286
|
|
|
282
287
|
function Shell() {
|
|
288
|
+
const [language, updateLanguage] = React.useState(() => normalizePaletteLanguage(navigator.language))
|
|
289
|
+
const platform = React.useMemo(() => ({
|
|
290
|
+
...basePlatform,
|
|
291
|
+
language,
|
|
292
|
+
fallbackLanguage: "en",
|
|
293
|
+
supportedLanguages: ["en", "ko"],
|
|
294
|
+
setLanguage: (nextLanguage) => {
|
|
295
|
+
const normalized = normalizePaletteLanguage(nextLanguage)
|
|
296
|
+
updateLanguage(normalized)
|
|
297
|
+
document.documentElement.lang = normalized
|
|
298
|
+
window.dispatchEvent(new CustomEvent("palette:language-change", { detail: { language: normalized } }))
|
|
299
|
+
},
|
|
300
|
+
}), [language])
|
|
301
|
+
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
document.documentElement.lang = language
|
|
304
|
+
}, [language])
|
|
305
|
+
|
|
306
|
+
React.useEffect(() => {
|
|
307
|
+
const onLanguageChange = () => updateLanguage(normalizePaletteLanguage(navigator.language))
|
|
308
|
+
window.addEventListener("languagechange", onLanguageChange)
|
|
309
|
+
return () => window.removeEventListener("languagechange", onLanguageChange)
|
|
310
|
+
}, [])
|
|
311
|
+
|
|
283
312
|
return React.createElement(PluginProvider, { value: platform },
|
|
284
313
|
React.createElement("main", { className: "palette-local-shell" },
|
|
285
314
|
React.createElement(Plugin, { platform }),
|