@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.
Files changed (30) hide show
  1. package/README.md +21 -3
  2. package/backend-sdk/palette_sdk/__init__.py +1 -1
  3. package/backend-sdk/palette_sdk/data_rooms.py +17 -0
  4. package/backend-sdk/palette_sdk/db/alembic_env.py +4 -2
  5. package/backend-sdk/palette_sdk/plugin_context.py +6 -0
  6. package/backend-sdk/pyproject.toml +1 -1
  7. package/docs/python-backend-sdk.md +641 -0
  8. package/lib/commands/build.js +4 -0
  9. package/lib/dev-simulator.js +30 -1
  10. package/package.json +2 -1
  11. package/template-fallback/frontend/src/index.tsx +35 -23
  12. package/template-fallback/frontend/src/translations.ts +36 -0
  13. package/template-fallback/package.json +1 -1
  14. package/template-fallback/palette-plugin.json +1 -1
  15. package/template-fallback/templates/dashboard/frontend/src/index.tsx +5 -3
  16. package/template-fallback/templates/dashboard/frontend/src/translations.ts +12 -0
  17. package/template-fallback/templates/dashboard/package.json +1 -1
  18. package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
  19. package/template-fallback/templates/database/frontend/src/index.tsx +13 -12
  20. package/template-fallback/templates/database/frontend/src/translations.ts +30 -0
  21. package/template-fallback/templates/database/package.json +1 -1
  22. package/template-fallback/templates/database/palette-plugin.json +1 -1
  23. package/template-fallback/templates/external-service/frontend/src/index.tsx +5 -3
  24. package/template-fallback/templates/external-service/frontend/src/translations.ts +12 -0
  25. package/template-fallback/templates/external-service/package.json +1 -1
  26. package/template-fallback/templates/external-service/palette-plugin.json +1 -1
  27. package/template-fallback/templates/frontend-only/frontend/src/index.tsx +6 -3
  28. package/template-fallback/templates/frontend-only/frontend/src/translations.ts +12 -0
  29. package/template-fallback/templates/frontend-only/package.json +1 -1
  30. 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.
@@ -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) {
@@ -229,7 +229,7 @@ async function apiFetch(path, init) {
229
229
  return fetch(target, init)
230
230
  }
231
231
 
232
- const platform = {
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 }),