@palettelab/cli 0.3.43 → 0.3.44

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
@@ -328,6 +328,8 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
328
328
  return {"room": room, "folder": folder, "bytes": len(content or b"")}
329
329
  ```
330
330
 
331
+ App storage is different from Data Room uploads. Use `ctx.storage` or `palette.storage` for app-owned files written directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` / `palette.dataRooms` only when the file should be managed as a Data Room document.
332
+
331
333
  Python backend app-storage example:
332
334
 
333
335
  ```python
@@ -740,6 +740,8 @@ Local mocks live in `.palette/app-services.local.json`:
740
740
  }
741
741
  ```
742
742
 
743
+ App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
744
+
743
745
  Palette scopes storage the same way. Files written through `ctx.storage` or the
744
746
  frontend storage client live under:
745
747
 
@@ -145,7 +145,7 @@ function ensurePythonEnv(cwd, devDir, manifest) {
145
145
  return venvPython
146
146
  }
147
147
 
148
- function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
148
+ function writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort) {
149
149
  const runner = path.join(devDir, "backend_runner.py")
150
150
  const sdkPath = localBackendSdkPath()
151
151
  const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
@@ -159,10 +159,12 @@ import importlib.util
159
159
  import json
160
160
  import os
161
161
  import pathlib
162
+ import re
162
163
  import sys
164
+ import uuid
163
165
  from types import SimpleNamespace
164
166
 
165
- from fastapi import FastAPI, Request
167
+ from fastapi import FastAPI, HTTPException, Request, Response
166
168
  from fastapi.middleware.cors import CORSMiddleware
167
169
  from starlette.middleware.base import BaseHTTPMiddleware
168
170
 
@@ -175,6 +177,7 @@ DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///$
175
177
  DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
176
178
  DEV_CONNECTIONS = json.loads(${JSON.stringify(JSON.stringify(devConnections))})
177
179
  DEV_APP_MOCKS = json.loads(${JSON.stringify(JSON.stringify(devAppMocks))})
180
+ BACKEND_BASE = "http://127.0.0.1:${backendPort}"
178
181
 
179
182
  def _service_enabled(name: str) -> bool:
180
183
  services = MANIFEST.get("platform_services") or []
@@ -209,6 +212,34 @@ if _service_enabled("storage"):
209
212
  organization_name="Palette Dev",
210
213
  )
211
214
 
215
+ LOCAL_UPLOADS = {}
216
+ CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
217
+
218
+ def _local_storage_enabled():
219
+ return _service_enabled("storage") and DEV_STORAGE is not None
220
+
221
+ def _parse_content_range(value: str | None):
222
+ if not value:
223
+ raise HTTPException(status_code=411, detail="Content-Range header is required")
224
+ match = CONTENT_RANGE_RE.match(value)
225
+ if not match:
226
+ raise HTTPException(status_code=400, detail="Invalid Content-Range header")
227
+ start = int(match.group("start"))
228
+ end = int(match.group("end"))
229
+ total = int(match.group("total"))
230
+ if end < start or total < 0 or end >= total:
231
+ raise HTTPException(status_code=400, detail="Invalid Content-Range byte range")
232
+ return start, end, total
233
+
234
+ def _require_local_storage(plugin_id: str):
235
+ if plugin_id != MANIFEST.get("id", ""):
236
+ raise HTTPException(status_code=404, detail="App not found")
237
+ if not _local_storage_enabled():
238
+ raise HTTPException(status_code=403, detail='App must declare platform_services: ["storage"]')
239
+
240
+ def _api_url(path: str):
241
+ return f"{BACKEND_BASE}/api/v1{path}"
242
+
212
243
  class LocalAppInteropService:
213
244
  def service(self, service_id: str):
214
245
  return LocalAppServiceClient(service_id)
@@ -310,6 +341,84 @@ app.add_middleware(
310
341
  app.add_middleware(DevPluginContextMiddleware)
311
342
  app.include_router(router, prefix=f"/api/v1/plugins/{MANIFEST['id']}")
312
343
 
344
+ @app.post("/api/v1/app-storage/{plugin_id}/uploads")
345
+ async def create_local_app_storage_upload(plugin_id: str, request: Request):
346
+ _require_local_storage(plugin_id)
347
+ body = await request.json()
348
+ filename = body.get("filename") or "upload"
349
+ content_type = body.get("content_type") or "application/octet-stream"
350
+ size = int(body.get("size") or 0)
351
+ key = body.get("key")
352
+ chunk_size = int(body.get("chunk_size") or 8 * 1024 * 1024)
353
+ upload_id = uuid.uuid4().hex
354
+ object_path = DEV_STORAGE.object_path(filename, key=key)
355
+ temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
356
+ temp_path.parent.mkdir(parents=True, exist_ok=True)
357
+ temp_path.write_bytes(b"")
358
+ LOCAL_UPLOADS[upload_id] = {
359
+ "plugin_id": plugin_id,
360
+ "object_path": object_path,
361
+ "content_type": content_type,
362
+ "size": size,
363
+ "uploaded_bytes": 0,
364
+ "complete": False,
365
+ }
366
+ return {
367
+ "upload_id": upload_id,
368
+ "mode": "local_resumable",
369
+ "bucket": "local",
370
+ "object_path": object_path,
371
+ "file_url": DEV_STORAGE._target(object_path).as_uri(),
372
+ "upload_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/chunks"),
373
+ "status_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/status"),
374
+ "content_type": content_type,
375
+ "size": size,
376
+ "chunk_size": chunk_size,
377
+ }
378
+
379
+ @app.get("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/status")
380
+ async def local_app_storage_upload_status(plugin_id: str, upload_id: str):
381
+ _require_local_storage(plugin_id)
382
+ session = LOCAL_UPLOADS.get(upload_id)
383
+ if not session or session["plugin_id"] != plugin_id:
384
+ raise HTTPException(status_code=404, detail="Upload session not found")
385
+ return {
386
+ "upload_id": upload_id,
387
+ "object_path": session["object_path"],
388
+ "uploaded_bytes": session["uploaded_bytes"],
389
+ "size": session["size"],
390
+ "complete": session["complete"],
391
+ "file_url": DEV_STORAGE._target(session["object_path"]).as_uri(),
392
+ }
393
+
394
+ @app.put("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/chunks")
395
+ async def upload_local_app_storage_chunk(plugin_id: str, upload_id: str, request: Request):
396
+ _require_local_storage(plugin_id)
397
+ session = LOCAL_UPLOADS.get(upload_id)
398
+ if not session or session["plugin_id"] != plugin_id:
399
+ raise HTTPException(status_code=404, detail="Upload session not found")
400
+ if session["complete"]:
401
+ return Response(status_code=204)
402
+ start, end, total = _parse_content_range(request.headers.get("content-range"))
403
+ if total != session["size"]:
404
+ raise HTTPException(status_code=400, detail="Chunk total does not match upload size")
405
+ if start != session["uploaded_bytes"]:
406
+ raise HTTPException(status_code=409, detail=f"Expected chunk to start at byte {session['uploaded_bytes']}")
407
+ payload = await request.body()
408
+ if len(payload) != end - start + 1:
409
+ raise HTTPException(status_code=400, detail="Chunk size does not match Content-Range")
410
+ temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
411
+ with temp_path.open("ab") as fh:
412
+ fh.write(payload)
413
+ session["uploaded_bytes"] = end + 1
414
+ if session["uploaded_bytes"] >= session["size"]:
415
+ final_path = DEV_STORAGE._target(session["object_path"])
416
+ final_path.parent.mkdir(parents=True, exist_ok=True)
417
+ temp_path.replace(final_path)
418
+ session["complete"] = True
419
+ return Response(status_code=201)
420
+ return Response(status_code=308, headers={"Range": f"bytes=0-{session['uploaded_bytes'] - 1}"})
421
+
313
422
  @app.on_event("startup")
314
423
  async def create_local_database_tables():
315
424
  if engine is None:
@@ -328,7 +437,7 @@ function startBackend(cwd, devDir, manifest, backendPort) {
328
437
  if (!fs.existsSync(absEntry)) throw new Error(`backend entry not found: ${backendEntry}`)
329
438
 
330
439
  const python = ensurePythonEnv(cwd, devDir, manifest)
331
- const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry)
440
+ const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort)
332
441
  const sdkPath = localBackendSdkPath()
333
442
  const env = { ...process.env }
334
443
  if (sdkPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.43",
3
+ "version": "0.3.44",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"