diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
index 6682a02c7..57ee48e6c 100644
--- a/backend/app/api/__init__.py
+++ b/backend/app/api/__init__.py
@@ -25,6 +25,7 @@
from .chats import * # noqa: E402, F403
from .cloud import * # noqa: E402, F403
from .cloud_backups import * # noqa: E402, F403
+from .cloud_store import * # noqa: E402, F403
from .embedded_device import * # noqa: E402, F403
from .frame_bootstrap import * # noqa: E402, F403
from .frames import * # noqa: E402, F403
diff --git a/backend/app/api/cloud_store.py b/backend/app/api/cloud_store.py
new file mode 100644
index 000000000..8f92e54d6
--- /dev/null
+++ b/backend/app/api/cloud_store.py
@@ -0,0 +1,169 @@
+"""Publish scenes to the FrameOS Cloud store (STORE-TODO Phases 2-3).
+
+The payload is the same template interchange zip the backups use; the cloud
+keeps immutable versions per scene. Publishing needs the ``store:publish``
+scope on the cloud link. Browsing the public store needs nothing at all —
+it is a plain scenes repository at ``{provider}/api/store/repository.json``
+(auto-added per project in app/api/repositories.py).
+
+"My cloud drive" is the account's own scenes, private ones included. The
+browser cannot attach the link token to tags, so this module proxies
+the drive listing and preview images; zips install through the normal
+``POST /api/templates {url}`` flow, which attaches the link token for
+provider URLs (see cloud_headers_for_url).
+"""
+from __future__ import annotations
+
+import base64
+import io
+from http import HTTPStatus
+
+from arq import ArqRedis as Redis
+from fastapi import Depends, HTTPException
+from fastapi.responses import Response
+from PIL import Image
+from sqlalchemy.orm import Session
+
+from app.api.auth import get_current_user
+from app.api.cloud_backups import _require_linked, _require_user
+from app.api.templates import template_zip_bytes
+from app.database import get_db
+from app.models.frame import Frame
+from app.models.template import Template
+from app.models.user import User
+from app.redis import get_redis
+from app.schemas.cloud import CloudStatusResponse, CloudStorePublishRequest
+from app.tenancy import get_user_project
+from app.utils import cloud_link
+
+from . import api_user
+
+
+async def _template_from_request(
+ data: CloudStorePublishRequest, db: Session, redis: Redis, user: User
+) -> Template:
+ """The template to publish: an existing one, or a transient one built
+ from inline scenes ("Save to cloud drive" straight off a frame)."""
+ if data.template_id:
+ template = db.query(Template).filter_by(id=data.template_id).first()
+ if template is None or get_user_project(db, user, template.project_id) is None:
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Template not found")
+ return template
+
+ if not data.name or not data.scenes:
+ raise HTTPException(
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
+ detail="Provide template_id, or name and scenes",
+ )
+
+ template = Template(name=data.name, description=data.description, scenes=data.scenes, config={})
+
+ if data.from_frame_id:
+ frame = db.query(Frame).filter_by(id=data.from_frame_id).first()
+ if frame is None or get_user_project(db, user, frame.project_id) is None:
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
+ template.project_id = frame.project_id
+ image = None
+ if data.image_scene_id:
+ from app.models.scene_image import SceneImage
+
+ scene_image = (
+ db.query(SceneImage)
+ .filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id=data.image_scene_id)
+ .first()
+ )
+ if scene_image:
+ image = scene_image.image
+ if not image:
+ image = await redis.get(f"frame:{frame.id}:image")
+ if image:
+ try:
+ img_obj = Image.open(io.BytesIO(image))
+ template.image = image
+ template.image_width = img_obj.width
+ template.image_height = img_obj.height
+ except Exception: # noqa: BLE001 — a broken snapshot just means no preview
+ pass
+
+ return template
+
+
+@api_user.post("/cloud/store/publish", response_model=CloudStatusResponse)
+async def publish_template_to_store(
+ data: CloudStorePublishRequest,
+ db: Session = Depends(get_db),
+ redis: Redis = Depends(get_redis),
+ current_user: User | None = Depends(get_current_user),
+):
+ user = _require_user(current_user)
+ link, access_token = _require_linked(db, "store:publish")
+ template = await _template_from_request(data, db, redis, user)
+
+ payload: dict = {
+ "name": template.name,
+ "content_base64": base64.b64encode(template_zip_bytes(template)).decode(),
+ "content_type": "application/zip",
+ }
+ if template.description:
+ payload["description"] = template.description
+ # Omitted visibility means: private on first publish, unchanged afterwards.
+ if data.visibility in ("private", "public"):
+ payload["visibility"] = data.visibility
+
+ try:
+ status_code, response = await cloud_link.store_publish(link.provider_url, access_token, payload)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}"
+ ) from exc
+ if status_code != 200:
+ detail = response.get("error") or f"unexpected status {status_code}"
+ raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}")
+ return {"status": "published", "scene": response.get("scene")}
+
+
+@api_user.get("/cloud/store/drive", response_model=CloudStatusResponse)
+async def cloud_store_drive(
+ db: Session = Depends(get_db),
+ current_user: User | None = Depends(get_current_user),
+):
+ _require_user(current_user)
+ link, access_token = _require_linked(db, "store:publish")
+ try:
+ status_code, payload = await cloud_link.store_drive(link.provider_url, access_token)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}"
+ ) from exc
+ if status_code != 200:
+ detail = payload.get("error") or f"unexpected status {status_code}"
+ raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}")
+
+ # Preview images of private scenes need the link token, which the browser
+ # does not have — point them at our authenticated proxy below instead.
+ for template in payload.get("templates", []):
+ scene_id = template.get("sceneId")
+ if scene_id and template.get("image"):
+ template["image"] = f"/api/cloud/store/drive/image/{scene_id}"
+ return payload
+
+
+@api_user.get("/cloud/store/drive/image/{scene_id}")
+async def cloud_store_drive_image(
+ scene_id: str,
+ db: Session = Depends(get_db),
+ current_user: User | None = Depends(get_current_user),
+):
+ _require_user(current_user)
+ link, access_token = _require_linked(db, "store:publish")
+ try:
+ status_code, content_type, content = await cloud_link.cloud_get_binary(
+ link.provider_url, f"/api/store/scenes/{scene_id}/image", access_token
+ )
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}"
+ ) from exc
+ if status_code != 200:
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found")
+ return Response(content, media_type=content_type, headers={"cache-control": "private, max-age=300"})
diff --git a/backend/app/api/repositories.py b/backend/app/api/repositories.py
index 21b888f7b..7b4a84806 100644
--- a/backend/app/api/repositories.py
+++ b/backend/app/api/repositories.py
@@ -31,6 +31,7 @@
FRAMEOS_SAMPLES_URL = "https://repo.frameos.net/samples/repository.json"
FRAMEOS_GALLERY_URL = "https://repo.frameos.net/gallery/repository.json"
+CLOUD_STORE_REPOSITORY_MARKER = "@system/cloud_store_repository_added"
SYSTEM_REPOSITORIES_PATH = Path(__file__).resolve().parents[3] / "repo" / "scenes"
@@ -284,6 +285,53 @@ async def get_repositories(db: Session = Depends(get_db)):
db.add(Settings(project_id=project_id, key="@system/repository_global_cleanup", value="true"))
db.commit()
+ # Seed the connected provider's store once per provider. The marker
+ # records its URL so changing providers replaces the old store, while
+ # deleting the repository remains respected until the provider changes.
+ from app.models.cloud import current_cloud_backend_link
+
+ link = current_cloud_backend_link(db)
+ if link is not None and link.status == "connected" and link.provider_url:
+ store_url = link.provider_url.rstrip("/") + "/api/store/repository.json"
+ marker = db.query(Settings).filter_by(
+ project_id=project_id, key=CLOUD_STORE_REPOSITORY_MARKER
+ ).first()
+ marker_url = marker.value if marker is not None and isinstance(marker.value, str) else None
+ should_seed = marker is None or (marker_url is not None and marker_url != store_url)
+
+ if marker is not None and marker_url in (None, "true"):
+ # Migrate the old boolean marker. An absent repository is
+ # ambiguous and may have been explicitly deleted, so preserve
+ # that choice. An old provider repository makes a URL change
+ # observable and safe to migrate.
+ legacy_repositories = db.query(Repository).filter(
+ Repository.project_id == project_id,
+ Repository.url.like("%/api/store/repository.json"),
+ ).all()
+ should_seed = bool(legacy_repositories) and all(repo.url != store_url for repo in legacy_repositories)
+ for repository in legacy_repositories:
+ if repository.url != store_url:
+ db.delete(repository)
+ marker.value = store_url
+ elif marker is not None and marker_url != store_url:
+ old_repository = db.query(Repository).filter_by(project_id=project_id, url=marker_url).first()
+ if old_repository is not None:
+ db.delete(old_repository)
+ marker.value = store_url
+
+ if marker is None:
+ marker = Settings(project_id=project_id, key=CLOUD_STORE_REPOSITORY_MARKER, value=store_url)
+ db.add(marker)
+
+ if should_seed and not db.query(Repository).filter_by(project_id=project_id, url=store_url).first():
+ store_repository = Repository(project_id=project_id, name="FrameOS Cloud store", url=store_url)
+ try:
+ await store_repository.update_templates()
+ except Exception: # noqa: BLE001 — provider may be unreachable; seed anyway
+ pass
+ db.add(store_repository)
+ db.commit()
+
repositories = project_query(db, Repository).all()
for r in repositories:
diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py
index 76cab7451..998a58eda 100644
--- a/backend/app/api/templates.py
+++ b/backend/app/api/templates.py
@@ -1,5 +1,7 @@
import base64
import io
+import re
+import urllib.parse
import zipfile
import json
import string
@@ -37,6 +39,29 @@ def safe_template_name(template: Template) -> str:
return ' '.join(template_name.split()) or 'Template'
+_FRAMEOS_ZIP_META_RE = re.compile(
+ r']*?(?:name=["\']frameos:zip["\'][^>]*?content=["\']([^"\']+)["\']'
+ r'|content=["\']([^"\']+)["\'][^>]*?name=["\']frameos:zip["\'])',
+ re.IGNORECASE,
+)
+
+
+def frameos_zip_url_from_html(content: bytes, page_url: str) -> str | None:
+ """The template zip URL a scene page advertises via
+ , resolved against the page URL."""
+ try:
+ html = content[:262144].decode("utf-8", errors="replace")
+ except Exception: # noqa: BLE001
+ return None
+ match = _FRAMEOS_ZIP_META_RE.search(html)
+ if not match:
+ return None
+ zip_url = (match.group(1) or match.group(2) or "").strip().replace("&", "&")
+ if not zip_url:
+ return None
+ return urllib.parse.urljoin(page_url, zip_url)
+
+
def template_zip_bytes(template: Template) -> bytes:
"""The template interchange zip: {name}/template.json + scenes.json + image.jpg.
Also the payload format for cloud template backups (CLOUD-TODO Phase 3)."""
@@ -133,6 +158,9 @@ async def create_template(
name = name or parsed_json.get('name')
description = description or parsed_json.get('description')
from_frame_id = from_frame_id or parsed_json.get('from_frame_id')
+ # When saving specific scenes off a frame, the preview should be the
+ # snapshot of one of *those* scenes, not whatever the frame shows now.
+ image_scene_id = parsed_json.get('image_scene_id')
# Scenes/config might come as JSON arrays or as strings
if not scenes and parsed_json.get('scenes') is not None:
@@ -157,11 +185,28 @@ async def create_template(
file_bytes = await file.read()
zip_file = zipfile.ZipFile(io.BytesIO(file_bytes))
elif url:
- # If we have a URL, fetch it
+ # If we have a URL, fetch it. URLs on the linked cloud provider get
+ # the link token attached so private "My cloud drive" scenes install.
+ from app.utils.cloud_backup import cloud_headers_for_url
+
async with httpx.AsyncClient() as client:
- resp = await client.get(url)
- resp.raise_for_status()
- zip_file = zipfile.ZipFile(io.BytesIO(resp.content))
+ resp = await client.get(url, headers=cloud_headers_for_url(db, url))
+ resp.raise_for_status()
+ content = resp.content
+ if not zipfile.is_zipfile(io.BytesIO(content)):
+ # Not a zip: maybe a scene page (e.g. a FrameOS Cloud store
+ # page) that advertises its zip in a
+ # tag — so pasting the page URL installs the scene.
+ zip_url = frameos_zip_url_from_html(content, url)
+ if not zip_url:
+ raise HTTPException(
+ status_code=422,
+ detail="URL is neither a template .zip nor a page with a frameos:zip meta tag",
+ )
+ resp = await client.get(zip_url, headers=cloud_headers_for_url(db, zip_url))
+ resp.raise_for_status()
+ content = resp.content
+ zip_file = zipfile.ZipFile(io.BytesIO(content))
data = {
"from_frame_id": from_frame_id,
@@ -207,8 +252,10 @@ async def create_template(
image_path = img_val[len('./'):]
img_val = zip_file.read(f'{folder_name}{image_path}')
elif img_val.startswith('http:') or img_val.startswith('https:'):
+ from app.utils.cloud_backup import cloud_headers_for_url
+
async with httpx.AsyncClient() as client:
- resp = await client.get(img_val)
+ resp = await client.get(img_val, headers=cloud_headers_for_url(db, img_val))
resp.raise_for_status()
img_val = resp.content
else:
@@ -225,8 +272,20 @@ async def create_template(
frame_id = data['from_frame_id']
frame = db.query(Frame).filter_by(project_id=project_id, id=frame_id).first()
if frame:
- cache_key = f'frame:{frame.id}:image'
- last_image = await redis.get(cache_key)
+ last_image = None
+ if image_scene_id:
+ from app.models.scene_image import SceneImage
+
+ scene_image = (
+ db.query(SceneImage)
+ .filter_by(project_id=project_id, frame_id=frame.id, scene_id=image_scene_id)
+ .first()
+ )
+ if scene_image:
+ last_image = scene_image.image
+ if not last_image:
+ cache_key = f'frame:{frame.id}:image'
+ last_image = await redis.get(cache_key)
if last_image:
try:
img_obj = Image.open(io.BytesIO(last_image))
diff --git a/backend/app/api/tests/test_cloud_e2e.py b/backend/app/api/tests/test_cloud_e2e.py
new file mode 100644
index 000000000..cbf5de1e6
--- /dev/null
+++ b/backend/app/api/tests/test_cloud_e2e.py
@@ -0,0 +1,190 @@
+"""End-to-end happy path against a real FrameOS Cloud dev server (Phase 0).
+
+Skipped unless a local provider is running and the env vars below are set.
+The private frameos-cloud repo ships `scripts/e2e-frameos.sh`, which boots the
+dev server, creates a verified account with a browser session, and runs this
+file with:
+
+ FRAMEOS_CLOUD_E2E_URL e.g. http://localhost:3000
+ FRAMEOS_CLOUD_E2E_COOKIE the account's session cookie ("name=value")
+ FRAMEOS_CLOUD_E2E_EMAIL the account's email (for assertions)
+
+Everything here goes over real HTTP: the device-authorization link, grants
+sync, the login handoff (Phase 1), and config backups (Phase 3).
+"""
+import os
+
+import httpx
+import pytest
+
+from app.models.cloud import CloudIdentity
+from app.models.frame import Frame
+
+E2E_URL = os.environ.get("FRAMEOS_CLOUD_E2E_URL")
+E2E_COOKIE = os.environ.get("FRAMEOS_CLOUD_E2E_COOKIE")
+E2E_EMAIL = os.environ.get("FRAMEOS_CLOUD_E2E_EMAIL")
+
+pytestmark = pytest.mark.skipif(
+ not E2E_URL or not E2E_COOKIE,
+ reason="Set FRAMEOS_CLOUD_E2E_URL and FRAMEOS_CLOUD_E2E_COOKIE (see frameos-cloud/scripts/e2e-frameos.sh)",
+)
+
+# Connect with the bare link scopes; the included ("safe") features are then
+# auto-granted through the in-place feature-change flow, and cloud login — a
+# security feature — needs the owner's approval (both exercised below).
+SCOPES = ["backend:link", "backend:read"]
+INCLUDED_SCOPES = ["backup:scenes", "backup:frames", "store:publish"]
+ALL_SCOPES = SCOPES + INCLUDED_SCOPES + ["auth:login"]
+
+
+def cloud_client() -> httpx.AsyncClient:
+ return httpx.AsyncClient(
+ base_url=E2E_URL,
+ headers={"cookie": E2E_COOKIE, "origin": E2E_URL},
+ timeout=30.0,
+ follow_redirects=False,
+ )
+
+
+@pytest.mark.asyncio
+async def test_cloud_link_login_and_backups_happy_path(async_client, db):
+ # ---- Phase 0: link through the device authorization flow ----------------
+ response = await async_client.post("/api/cloud/provider", json={"provider_url": E2E_URL})
+ assert response.status_code == 200, response.text
+
+ response = await async_client.post("/api/cloud/connect", json={"scopes": SCOPES})
+ assert response.status_code == 200, response.text
+ connection = response.json()["connection"]
+ assert connection["user_code"]
+
+ async with cloud_client() as cloud:
+ approve = await cloud.post("/api/device/authorize", json={"user_code": connection["user_code"]})
+ assert approve.status_code == 200, approve.text
+
+ response = await async_client.post("/api/cloud/poll")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "connected", data
+ assert sorted(data["link"]["scopes"]) == sorted(SCOPES)
+ if E2E_EMAIL:
+ assert data["link"]["account_email"] == E2E_EMAIL
+
+ # ---- enabled features: included scopes are auto-granted ------------------
+ # /api/cloud/features always sends the included feature scopes along;
+ # adding only safe scopes needs no approval on the provider.
+ response = await async_client.post("/api/cloud/features", json={"scopes": []})
+ assert response.status_code == 200, response.text
+ data = response.json()
+ assert data.get("upgrade") is None, data
+ assert sorted(data["link"]["scopes"]) == sorted(SCOPES + INCLUDED_SCOPES)
+
+ # ---- enabled features: cloud login needs the owner's approval ------------
+ response = await async_client.post("/api/cloud/features", json={"scopes": ["auth:login"]})
+ assert response.status_code == 200, response.text
+ upgrade = response.json().get("upgrade")
+ assert upgrade and upgrade["user_code"], response.text
+
+ async with cloud_client() as cloud:
+ approve = await cloud.post("/api/device/authorize", json={"user_code": upgrade["user_code"]})
+ assert approve.status_code == 200, approve.text
+
+ response = await async_client.post("/api/cloud/poll")
+ assert response.status_code == 200
+ data = response.json()
+ assert data.get("upgrade") is None
+ assert sorted(data["link"]["scopes"]) == sorted(ALL_SCOPES)
+
+ # ---- Phase 1: login handoff over real HTTP -------------------------------
+ response = await async_client.post("/api/cloud/identity/link", json={})
+ assert response.status_code == 200, response.text
+ authorization_url = response.json()["authorization_url"]
+ assert authorization_url.startswith(E2E_URL)
+
+ async with cloud_client() as cloud:
+ authorize = await cloud.get(authorization_url)
+ assert authorize.status_code in (302, 303, 307), authorize.text
+ callback_url = authorize.headers["location"]
+ assert callback_url.startswith("http://test/api/cloud/login/callback"), callback_url
+ assert "code=" in callback_url and "state=" in callback_url
+
+ response = await async_client.get(callback_url[len("http://test"):])
+ assert response.status_code == 303, response.text
+ assert response.headers["location"] == "/settings"
+
+ identity = db.query(CloudIdentity).first()
+ assert identity is not None
+ if E2E_EMAIL:
+ assert identity.email == E2E_EMAIL
+
+ status = (await async_client.get("/api/cloud/status")).json()
+ assert status["identity"] is not None
+
+ # A second handoff now signs the linked user in (fresh session cookie).
+ response = await async_client.post("/api/cloud/login/start", json={"next": "/frames"})
+ assert response.status_code == 200, response.text
+ async with cloud_client() as cloud:
+ authorize = await cloud.get(response.json()["authorization_url"])
+ callback_url = authorize.headers["location"]
+ response = await async_client.get(callback_url[len("http://test"):])
+ assert response.status_code == 303
+ assert response.headers["location"] == "/frames"
+ assert "frameos_session" in response.headers.get("set-cookie", "")
+
+ # ---- Phase 3: config backups over real HTTP ------------------------------
+ frame = Frame(
+ project_id=async_client.project_id,
+ name="E2E frame",
+ frame_host="10.0.0.99",
+ ssh_pass="e2e-secret-pass",
+ status="ready",
+ scenes=[{"id": "scene-e2e", "nodes": []}],
+ )
+ db.add(frame)
+ db.commit()
+ db.refresh(frame)
+
+ # The scope alone is a permission, not the feature: uploads are refused
+ # until the local backup switches are turned on.
+ response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id})
+ assert response.status_code == 403, response.text
+
+ response = await async_client.post("/api/cloud/backup-features", json={"scenes": True, "frames": True})
+ assert response.status_code == 200, response.text
+ data = response.json()
+ assert data["backup_scenes_enabled"] is True
+ assert data["backup_frames_enabled"] is True
+
+ response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id})
+ assert response.status_code == 200, response.text
+
+ response = await async_client.get("/api/cloud/backups")
+ assert response.status_code == 200
+ backups = response.json()["backups"]
+ match = next(b for b in backups if b["item_key"] == f"frame-{frame.id}")
+ assert match["kind"] == "frames"
+
+ # The blob stored in the cloud is sanitized.
+ async with cloud_client() as cloud:
+ blob = await cloud.get(
+ f"/api/backends/backups/{match['id']}",
+ headers={"cookie": "", "origin": E2E_URL},
+ )
+ # (bearer-only endpoint; expect 401 without the link token — the local
+ # restore path below proves the content instead)
+ assert blob.status_code == 401
+
+ response = await async_client.post(
+ "/api/cloud/backups/restore",
+ json={"backup_id": match["id"], "project_id": async_client.project_id},
+ )
+ assert response.status_code == 200, response.text
+ restored_id = response.json()["id"]
+ restored = db.get(Frame, restored_id)
+ assert restored.name == "E2E frame"
+ assert restored.scenes == [{"id": "scene-e2e", "nodes": []}]
+ assert restored.ssh_pass is None # the secret never reached the cloud
+
+ # ---- unlink ---------------------------------------------------------------
+ response = await async_client.post("/api/cloud/disconnect")
+ assert response.status_code == 200
+ assert response.json()["status"] == "disconnected"
diff --git a/backend/app/api/tests/test_cloud_store.py b/backend/app/api/tests/test_cloud_store.py
new file mode 100644
index 000000000..81d7d0926
--- /dev/null
+++ b/backend/app/api/tests/test_cloud_store.py
@@ -0,0 +1,268 @@
+"""Store publishing (STORE-TODO Phase 2): publish templates to the cloud store."""
+import base64
+import io
+import json
+import zipfile
+
+import pytest
+
+from app.models.cloud import CloudBackendLink
+from app.models.template import Template
+from app.utils import cloud_link
+from app.utils.versions import current_frameos_version
+
+PROVIDER = "https://cloud.frameos.net"
+
+STORE_SCOPES = "backend:link backend:read store:publish"
+
+
+def make_connected_link(db, scope=STORE_SCOPES):
+ link = CloudBackendLink(
+ provider_url=PROVIDER,
+ status="connected",
+ access_token=cloud_link.encrypt_cloud_secret("link-token-secret"),
+ linked_client_id="lc-1",
+ scope=scope,
+ local_origin="http://test",
+ cloud_account_id="acc-1",
+ )
+ db.add(link)
+ db.commit()
+ return link
+
+
+def make_template(db, project_id):
+ template = Template(
+ project_id=project_id,
+ name="Sunrise Clock",
+ description="A calm sunrise clock",
+ scenes=[{"id": "scene-1", "nodes": []}],
+ config={},
+ )
+ db.add(template)
+ db.commit()
+ db.refresh(template)
+ return template
+
+
+@pytest.fixture
+def store_calls(monkeypatch):
+ calls = []
+ response = {
+ "status": "published",
+ "scene": {
+ "id": "scene-uuid",
+ "slug": "sunrise-clock",
+ "url": f"{PROVIDER}/scenes/sunrise-clock",
+ "version": 1,
+ "visibility": "private",
+ },
+ }
+
+ async def store_publish(*args):
+ calls.append(args)
+ return 200, response
+
+ monkeypatch.setattr(cloud_link, "store_publish", store_publish)
+ return calls
+
+
+@pytest.mark.asyncio
+async def test_publish_sends_template_zip(async_client, db, store_calls):
+ make_connected_link(db)
+ template = make_template(db, async_client.project_id)
+
+ response = await async_client.post("/api/cloud/store/publish", json={"template_id": str(template.id)})
+ assert response.status_code == 200, response.text
+ assert response.json()["scene"]["slug"] == "sunrise-clock"
+
+ provider_url, token, payload = store_calls[0]
+ assert provider_url == PROVIDER
+ assert payload["name"] == "Sunrise Clock"
+ assert payload["description"] == "A calm sunrise clock"
+ assert payload["content_type"] == "application/zip"
+ assert "visibility" not in payload
+
+ # The payload is the standard template interchange zip.
+ zip_bytes = base64.b64decode(payload["content_base64"])
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
+ names = archive.namelist()
+ manifest_path = next(name for name in names if name.endswith("template.json"))
+ scenes_path = next(name for name in names if name.endswith("scenes.json"))
+ manifest = json.loads(archive.read(manifest_path))
+ scenes = json.loads(archive.read(scenes_path))
+ assert manifest["name"] == "Sunrise Clock"
+ # Zips are stamped with the FrameOS version they were exported with; the
+ # store surfaces it on scene listings.
+ assert manifest["frameosVersion"] == current_frameos_version()
+ assert scenes == [{"id": "scene-1", "nodes": []}]
+
+
+@pytest.mark.asyncio
+async def test_publish_passes_explicit_visibility(async_client, db, store_calls):
+ make_connected_link(db)
+ template = make_template(db, async_client.project_id)
+
+ response = await async_client.post(
+ "/api/cloud/store/publish", json={"template_id": str(template.id), "visibility": "public"}
+ )
+ assert response.status_code == 200
+ assert store_calls[0][2]["visibility"] == "public"
+
+
+@pytest.mark.asyncio
+async def test_publish_requires_scope(async_client, db, store_calls):
+ make_connected_link(db, scope="backend:link backend:read")
+ template = make_template(db, async_client.project_id)
+
+ response = await async_client.post("/api/cloud/store/publish", json={"template_id": str(template.id)})
+ assert response.status_code == 403
+ assert store_calls == []
+
+
+@pytest.mark.asyncio
+async def test_publish_requires_link(async_client, db, store_calls):
+ template = make_template(db, async_client.project_id)
+ response = await async_client.post("/api/cloud/store/publish", json={"template_id": str(template.id)})
+ assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_publish_unknown_template_404s(async_client, db, store_calls):
+ make_connected_link(db)
+ response = await async_client.post(
+ "/api/cloud/store/publish", json={"template_id": "00000000-0000-0000-0000-000000000000"}
+ )
+ assert response.status_code == 404
+
+
+def make_frame(db, project_id):
+ from app.models.frame import Frame
+
+ frame = Frame(
+ project_id=project_id,
+ name="Kitchen frame",
+ frame_host="10.0.0.5",
+ ssh_user="pi",
+ status="ready",
+ scenes=[{"id": "scene-1", "nodes": []}, {"id": "scene-2", "nodes": []}],
+ )
+ db.add(frame)
+ db.commit()
+ db.refresh(frame)
+ return frame
+
+
+@pytest.mark.asyncio
+async def test_publish_inline_scenes_from_frame(async_client, db, store_calls):
+ make_connected_link(db)
+ frame = make_frame(db, async_client.project_id)
+
+ response = await async_client.post(
+ "/api/cloud/store/publish",
+ json={
+ "name": "Straight off the frame",
+ "description": "One scene",
+ "scenes": [{"id": "scene-2", "nodes": []}],
+ "from_frame_id": frame.id,
+ "image_scene_id": "scene-2",
+ },
+ )
+ assert response.status_code == 200, response.text
+
+ payload = store_calls[0][2]
+ assert payload["name"] == "Straight off the frame"
+ zip_bytes = base64.b64decode(payload["content_base64"])
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
+ scenes_path = next(name for name in archive.namelist() if name.endswith("scenes.json"))
+ scenes = json.loads(archive.read(scenes_path))
+ assert scenes == [{"id": "scene-2", "nodes": []}]
+ # Nothing is persisted locally: publishing to the cloud drive is not "save to my scenes".
+ from app.models.template import Template as TemplateModel
+
+ assert db.query(TemplateModel).filter_by(name="Straight off the frame").count() == 0
+
+
+@pytest.mark.asyncio
+async def test_publish_inline_requires_name_and_scenes(async_client, db, store_calls):
+ make_connected_link(db)
+ response = await async_client.post("/api/cloud/store/publish", json={"name": "No scenes"})
+ assert response.status_code == 422
+ assert store_calls == []
+
+
+@pytest.mark.asyncio
+async def test_publish_inline_unknown_frame_404s(async_client, db, store_calls):
+ make_connected_link(db)
+ response = await async_client.post(
+ "/api/cloud/store/publish",
+ json={"name": "X", "scenes": [{"id": "s", "nodes": []}], "from_frame_id": 424242},
+ )
+ assert response.status_code == 404
+ assert store_calls == []
+
+
+@pytest.mark.asyncio
+async def test_drive_lists_scenes_and_proxies_images(async_client, db, monkeypatch):
+ make_connected_link(db)
+
+ async def store_drive(provider_url, access_token):
+ assert provider_url == PROVIDER
+ assert access_token == "link-token-secret"
+ return 200, {
+ "name": "My cloud drive",
+ "templates": [
+ {
+ "id": "sunrise-clock",
+ "name": "Sunrise Clock",
+ "sceneId": "11111111-1111-1111-1111-111111111111",
+ "image": f"{PROVIDER}/api/store/scenes/11111111-1111-1111-1111-111111111111/image",
+ "zip": f"{PROVIDER}/api/store/scenes/11111111-1111-1111-1111-111111111111/download",
+ "visibility": "private",
+ }
+ ],
+ }
+
+ monkeypatch.setattr(cloud_link, "store_drive", store_drive)
+ response = await async_client.get("/api/cloud/store/drive")
+ assert response.status_code == 200, response.text
+ template = response.json()["templates"][0]
+ # Image URLs are rewritten to the authenticated backend proxy; zips keep
+ # their provider URL (POST /api/templates attaches the token for those).
+ assert template["image"] == "/api/cloud/store/drive/image/11111111-1111-1111-1111-111111111111"
+ assert template["zip"].startswith(PROVIDER)
+
+
+@pytest.mark.asyncio
+async def test_drive_requires_store_scope(async_client, db):
+ make_connected_link(db, scope="backend:link backend:read")
+ response = await async_client.get("/api/cloud/store/drive")
+ assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_drive_image_proxy(async_client, db, monkeypatch):
+ make_connected_link(db)
+
+ async def cloud_get_binary(provider_url, path, access_token):
+ assert path == "/api/store/scenes/abc/image"
+ assert access_token == "link-token-secret"
+ return 200, "image/jpeg", b"jpeg-bytes"
+
+ monkeypatch.setattr(cloud_link, "cloud_get_binary", cloud_get_binary)
+ response = await async_client.get("/api/cloud/store/drive/image/abc")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("image/jpeg")
+ assert response.content == b"jpeg-bytes"
+
+
+def test_cloud_headers_only_for_provider_urls(db):
+ from app.utils.cloud_backup import cloud_headers_for_url
+
+ make_connected_link(db)
+ assert cloud_headers_for_url(db, f"{PROVIDER}/api/store/scenes/x/download") == {
+ "authorization": "Bearer link-token-secret"
+ }
+ assert cloud_headers_for_url(db, "https://evil.example.com/api/store/scenes/x/download") == {}
+ assert cloud_headers_for_url(db, f"{PROVIDER}.evil.example.com/zip") == {}
+ assert cloud_headers_for_url(db, None) == {}
diff --git a/backend/app/api/tests/test_repositories.py b/backend/app/api/tests/test_repositories.py
index 6930acf8a..54d86625c 100644
--- a/backend/app/api/tests/test_repositories.py
+++ b/backend/app/api/tests/test_repositories.py
@@ -151,3 +151,102 @@ async def test_system_repository_image_allows_session_cookie_without_token(no_au
response = await no_auth_client.get('/api/repositories/system/samples/templates/Calendar/image?t=-1')
assert response.status_code == 200
assert response.content
+
+
+@pytest.mark.asyncio
+async def test_get_repositories_seeds_cloud_store_once(async_client, db, monkeypatch):
+ """A connected cloud link seeds once per provider; deletion is respected."""
+ from app.models.cloud import CloudBackendLink
+ from app.models.settings import Settings
+ from app.utils import cloud_link as cloud_link_utils
+
+ async def fake_update(self):
+ self.templates = []
+
+ monkeypatch.setattr(Repository, "update_templates", fake_update)
+
+ link = CloudBackendLink(
+ provider_url="https://cloud.frameos.net",
+ status="connected",
+ access_token=cloud_link_utils.encrypt_cloud_secret("link-token"),
+ linked_client_id="lc-1",
+ scope="backend:link backend:read",
+ local_origin="http://test",
+ )
+ db.add(link)
+ db.commit()
+
+ store_url = "https://cloud.frameos.net/api/store/repository.json"
+ response = await async_client.get('/api/repositories')
+ assert response.status_code == 200
+ assert store_url in [r["url"] for r in response.json()]
+ marker = db.query(Settings).filter_by(
+ project_id=async_client.project_id, key="@system/cloud_store_repository_added"
+ ).one()
+ assert marker.value == store_url
+
+ repo = db.query(Repository).filter_by(url=store_url).first()
+ delete = await async_client.delete(f'/api/repositories/{repo.id}')
+ assert delete.status_code == 200
+
+ # Not re-added behind the user's back.
+ response = await async_client.get('/api/repositories')
+ assert store_url not in [r["url"] for r in response.json()]
+
+ # Changing providers resets the one-time choice for the new provider and
+ # records its URL, even when the previous provider's store was deleted.
+ link.provider_url = "https://cloud.example.com"
+ db.commit()
+ next_store_url = "https://cloud.example.com/api/store/repository.json"
+ response = await async_client.get('/api/repositories')
+ assert next_store_url in [r["url"] for r in response.json()]
+ db.refresh(marker)
+ assert marker.value == next_store_url
+
+
+@pytest.mark.asyncio
+async def test_get_repositories_migrates_legacy_marker_after_provider_change(async_client, db, monkeypatch):
+ from app.models.cloud import CloudBackendLink
+ from app.models.settings import Settings
+
+ async def fake_update(self):
+ self.templates = []
+
+ monkeypatch.setattr(Repository, "update_templates", fake_update)
+ db.add(
+ CloudBackendLink(
+ provider_url="https://cloud.example.com",
+ status="connected",
+ linked_client_id="lc-2",
+ scope="backend:link backend:read",
+ local_origin="http://test",
+ )
+ )
+ db.add(
+ Settings(
+ project_id=async_client.project_id,
+ key="@system/cloud_store_repository_added",
+ value="true",
+ )
+ )
+ old_store_url = "https://cloud.frameos.net/api/store/repository.json"
+ db.add(Repository(project_id=async_client.project_id, name="Old cloud store", url=old_store_url))
+ db.commit()
+
+ new_store_url = "https://cloud.example.com/api/store/repository.json"
+ response = await async_client.get('/api/repositories')
+ assert response.status_code == 200
+ urls = [repository["url"] for repository in response.json()]
+ assert old_store_url not in urls
+ assert new_store_url in urls
+ marker = db.query(Settings).filter_by(
+ project_id=async_client.project_id, key="@system/cloud_store_repository_added"
+ ).one()
+ assert marker.value == new_store_url
+
+
+@pytest.mark.asyncio
+async def test_get_repositories_does_not_seed_store_without_link(async_client, db):
+ response = await async_client.get('/api/repositories')
+ assert response.status_code == 200
+ assert all("api/store/repository.json" not in (r["url"] or "") for r in response.json())
diff --git a/backend/app/api/tests/test_templates.py b/backend/app/api/tests/test_templates.py
index cc01a8910..e5f1ce68e 100644
--- a/backend/app/api/tests/test_templates.py
+++ b/backend/app/api/tests/test_templates.py
@@ -59,3 +59,103 @@ async def test_delete_nonexistent_template(async_client):
response = await async_client.delete('/api/templates/999999')
assert response.status_code == 404
assert "Template not found" in response.json()['detail']
+
+
+def test_frameos_zip_url_from_html():
+ from app.api.templates import frameos_zip_url_from_html
+
+ page = "https://cloud.example.com/scenes/sunrise"
+ # name before content, relative URL resolved against the page.
+ html = b'