diff --git a/CLOUD-TODO.md b/CLOUD-TODO.md new file mode 100644 index 000000000..0ebfcd4aa --- /dev/null +++ b/CLOUD-TODO.md @@ -0,0 +1,269 @@ +# FrameOS Cloud — plan and work tracker + +This file tracks the work to link FrameOS backends (and frames directly) to +FrameOS Cloud (`https://cloud.frameos.net`, private repo `../frameos-cloud`). + +Two repos are involved: + +- **frameos** (this repo, AGPL) — everything a self-hosted user runs. Must stay + fully functional without the cloud, and must talk to the cloud only through a + documented, reimplementable protocol (`docs/cloud-link.md`). Anyone can point + it at their own compatible server. +- **frameos-cloud** (private) — the hosted service: accounts, linked backends, + device-authorization flow, and the paid services below. + +## Principles + +1. **One-way, outbound-only.** Backends and frames initiate every connection. + Linking uses the OAuth 2.0 Device Authorization Grant (RFC 8628): the + backend asks the cloud for a code, the user approves it in their cloud + account in a browser, the backend polls and receives a scoped bearer token. + The cloud can never reach into a backend unless the backend has explicitly + opened a tunnel (remote access scope + user toggle). +2. **Tightly scoped permissions.** The token carries only the scopes the user + approved on the consent screen. Scopes are additive, revocable per scope, + and every privileged feature checks its scope on both sides. +3. **Local-first, cloud-optional.** Local login, local backups, and local + repositories always keep working. Every paid cloud service has a documented + do-it-yourself alternative. +4. **Upfront about money.** Services that cost real money to run (storage, + relay bandwidth, log retention) will be paid. The UI must say so before the + user enables them, never after. +5. **AGPL-clean boundary.** The protocol (endpoints, payloads, token semantics) + is documented in `docs/cloud-link.md` in this repo. The private repo may do + whatever it wants behind that contract. +6. **NO image proxies for frames. EVER.** Frames fetch and render images + directly from their sources — never through the backend or the cloud as a + resizing/fetching middleman, and not via host-side resize params either. + If a source serves images too large for a device, the fix is better + on-device streaming decode (incremental inflate + row-by-row + unfilter/scale into the render target). Proxies are acceptable for + in-browser previews only. Do not re-implement proxying; it has been built + and reverted before. + +## Permission scopes + +Requested at link time, shown on the cloud consent screen, stored with the +link, re-checkable via the grants endpoint. Proposed set: + +| Scope | Grants the cloud/backend the ability to | Phase | Paid? | +|---|---|---|---| +| `backend:link` | Base scope: identify this backend, sync inventory/health, rotate token | 0 | free | +| `auth:login` | Log users into this backend via their FrameOS Cloud account (login handoff) | 1 | free | +| `store:read` | Browse/install from the scene & app store (public repositories) | 2 | free | +| `store:publish` | Publish scenes/apps to the user's cloud collections or public store | 2 | free | +| `gallery:read` | Access curated photo galleries / gallery API | 2 | freemium | +| `backup:scenes` | Store the user's scene template collections in the cloud | 3 | paid tier | +| `backup:frames` | Back up frame metadata + scene JSON ("backup of your backup") | 3 | paid tier | +| `backup:assets` | Back up frame assets (SD card contents), client-side encrypted | 4 | paid (storage) | +| `remote:access` | Relay inbound connections so `something.local:8616` is reachable from cloud.frameos.net | 4 | paid (bandwidth) | +| `telemetry:logs` | Ship logs to cloud retention | 5 | paid (retention) | +| `telemetry:metrics` | Ship metrics to cloud retention | 5 | paid (retention) | + +Frames that link directly (no backend) use the same flow with `frame:link` as +the base scope plus the subset that makes sense on-device (`auth:login`, +`backup:assets`, `remote:access`). + +Notes: +- The UI never says "scopes" or "permissions": these are the install's + **enabled features**. They change in place through + `POST {provider}/api/backends/scopes` (Settings → FrameOS Cloud → Enabled + features) — removals apply immediately, additions of security-sensitive + scopes need a quick owner approval on the provider's device screen; the + link token never changes and nothing disconnects. +- Only security-sensitive features (cloud login, later remote access and + telemetry) get a cloud-approved opt-in toggle. The safe scopes — backups and + "Save and share scenes via the cloud" (`store:publish`) — are included with + every cloud account: requested at link time and auto-granted when added + later. +- The backup scopes are a permission, not the feature: nothing is uploaded + until the user flips the local scene/frame backup switches + (`backup_scenes_enabled` / `backup_frames_enabled`, instant, no cloud + approval). Same pattern as the future `remote:access` local toggle — + granting a scope alone must never move data. +- `remote:access` additionally requires an explicit on/off toggle locally; + granting the scope alone must not open a tunnel. +- "Paid?" is a product intention, not a commitment; free tiers likely include + small quotas. The linking/consent UI must show the price state of a scope. + +## Phases + +### Phase 0 — planning and linking (this branch) + +The connection itself: a backend (or frame) can be linked to a cloud account +and hold a scoped token. No user-visible service yet beyond "Connected". + +- [x] This plan. +- [x] Reuse the accidentally-shipped migration `2c4a6f8d9b10_cloud_auth_integration.py`. + It is load-bearing (later migration `961ada4af571` chains off it, and it + shipped in a release), so reverting would break user databases. Its + `cloud_backend_link` table is the storage for the link. `cloud_identity` + and `cloud_membership` stay unused until Phase 1. +- [x] Backend: `CloudBackendLink` model (`app/models/cloud.py`) over the + existing table; token encrypted at rest (Fernet keyed off `SECRET_KEY`). +- [x] Backend API (`app/api/cloud.py`, login-gated, not project-scoped — + the link belongs to the installation, not a project): + `GET /api/cloud/status`, `POST /api/cloud/connect`, + `POST /api/cloud/poll`, `POST /api/cloud/disconnect`, + `POST /api/cloud/provider` (edit server URL while disconnected). +- [x] Frontend: "FrameOS Cloud" settings section between Account and Settings + (`cloudLogic.tsx` + Settings.tsx section): connected state, connect + button with user code + verification link + countdown + polling, + provider URL editing when disconnected. +- [x] Frame (on-device admin): same UI, backed by Nim routes + `/api/cloud/*` in `frameos/src/frameos/server/routes/cloud_api_routes.nim`, + token stored in frame config. +- [x] Protocol documentation: `docs/cloud-link.md` (public, AGPL-side spec). +- [x] frameos-cloud: widen `allowedDeviceScopes` to the scope table above, + render requested scopes + paid markers on the consent screen. +- [x] frameos-cloud: distinguish backend links from direct frame links + (`client_kind` on `linked_clients` and `device_authorization_requests`, + set from the request body or derived from `frame:link`; consent screen + and account page say "frame" vs "backend"). +- [x] E2E happy-path test: local backend against a local frameos-cloud dev + server. `backend/app/api/tests/test_cloud_e2e.py` (skipped unless + `FRAMEOS_CLOUD_E2E_URL` is set); runner: frameos-cloud + `scripts/e2e-frameos.sh`. Covers link + login handoff + backups over + real HTTP. + +### Phase 1 — cloud login (auth) — done + +- [x] "Continue with FrameOS Cloud" on `/login` and first-run `/signup` (setup) + screens when available (login handoff: `POST /api/frameos/login/start` → + browser redirect → `POST /api/frameos/login/token`; the provider only + completes a handoff for the account that owns the link, and enforces the + `auth:login` scope). Frontend: `scenes/auth/cloudLoginLogic.ts`; + first-run device-link flow on the signup screen uses the open + `/api/cloud/setup/*` endpoints (valid only while no user exists) and + creates the first local user from the cloud principal. +- [x] Create/link local `User` for a cloud principal (`cloud_identity` table, + keyed on issuer+subject). Email match is NOT proof of ownership; a + logged-in user links explicitly via `POST /api/cloud/identity/link` + (same handoff, identity stored instead of a session). +- [x] Local-fallback toggle (`POST /api/cloud/local-fallback`): disabling + requires a connected link with `auth:login`, the user's identity matching + the link's owner account, and a live grants check; `/api/login` then + rejects passwords. Losing/disconnecting the link always re-enables it. +- [x] Same for the frame on-device `/admin` login (`frame:link` + `auth:login`): + open `/api/cloud/login/{options,start,callback}` in + `cloud_api_routes.nim`; a completed handoff mints the admin session. + (Also fixed the on-device login form to post to `/api/admin/login`.) +- [x] Grants sync loop (`app/cloud/sync.py`, arq worker singleton like + `app/ha/sync.py`): periodic grants + inventory heartbeat, memberships + synced into `cloud_membership`, 401 → local link reset + local login + re-enabled. Nudged over Redis channel `cloud_sync` on connect. + +### Phase 2 — store and galleries + +The store has its own tracker: `../frameos-cloud/STORE-TODO.md` (decisions, +threat model, phases). Protocol: `docs/cloud-link.md` § "Scene store". + +- [x] Cloud-hosted scene repositories browsable in the existing repositories + UI; the current repository JSON format is the interchange format. The + public store is a plain repository at + `{provider}/api/store/repository.json`, seeded once per project when a + cloud link exists (no `store:read` needed — it's public; the scope stays + reserved for private-collection browsing later). +- [x] Publish a scene/template to the store (`store:publish`): + `POST /api/cloud/store/publish` + "Save to cloud drive" in the + Templates panel, scene dropdowns, and the frames-home scene menus + (works on unsaved templates too — inline scenes straight off a frame). + Private by default, made public on the cloud website; npm-style + immutable versions; pre-publish content moderation on the provider, + then post-moderation (superadmin pull/feature, crates-style yank, + publisher bans, user reports). +- [x] "My cloud drive" section in the Templates panel: the account's own + store scenes (private + public), listed above "My local scenes", + collapsible, with a settings promo while not connected. Backed by + `GET /api/cloud/store/drive` (+ image proxy); private zips install via + the normal template-from-URL flow with the link token attached for + provider URLs. Repository templates show "by {author}" and a "shell" + risk badge with an install confirmation. +- [x] FrameOS version stamping: `template.json` gains `frameosVersion` at + export; the store keeps it per scene/version and shows it (listings, + scene pages, Templates rows — with a "newer than this install" upgrade + nudge). +- [x] `frameos-wasm` npm package (`frameos/wasm`): the emscripten scene + runtime + typed preview API + a showIf-aware management interface + (fields, event buttons, logs). Version always equals the `frameos` + release version (synced by `tools/update_versions.py`), published to + npm by the release workflow (needs the `NPM_TOKEN` repo secret). + frameos-cloud uses it for in-browser live previews on scene pages. +- [ ] Apps (not just scenes) in the store — needs a code-review/signing story + first (STORE-TODO Phase 3). +- [ ] Photo gallery service (`gallery:read`): curated feeds usable as image + sources in scenes, quota-limited free tier. + +### Phase 3 — config backups — done + +- [x] Scene template collection backup/restore (`backup:scenes`): the + template interchange zip is the payload; push via + `POST /api/cloud/backups/templates`, restore via + `POST /api/cloud/backups/restore`. Cloud storage: + `/api/backends/backups` (account-owned, replace-in-place per + `(account, kind, item_key)`, 8 MB/blob, 500/account). +- [x] Frame metadata + scenes backup (`backup:frames`), automatic after deploy + (the cloud sync worker watches `update_frame` broadcasts for a changed + `last_successful_deploy_at`). Local secrets (SSH creds, access keys, TLS + material, wifi passwords) are stripped before upload + (`app/utils/cloud_backup.py`); restores regenerate credentials. + Backups are account-owned, so a reinstalled backend that relinks via the + first-run cloud setup sees and restores them (Settings → FrameOS Cloud). +- [x] Export everything as a plain tarball too: `GET /api/backup/export` + (manifest + per-project frame JSON + template zips, full fidelity since + it stays local). + +### Phase 4 — heavy transport + +- [ ] Asset backup (`backup:assets`): client-side encryption (age or similar, + key never leaves the user), content-addressed chunks, resumable. +- [ ] Remote access (`remote:access`): persistent outbound WebSocket tunnel + from backend/frame to a cloud relay (pattern exists in + `app/ws/remote_bridge.py`); reach your backend/frame UI from + cloud.frameos.net. Explicit local toggle, visible "tunnel open" status. +- [ ] Direct frame login from the cloud via that relay (`/admin` handoff). + +### Phase 5 — observability + +- [ ] Log shipping + retention (`telemetry:logs`). +- [ ] Metrics shipping + dashboards (`telemetry:metrics`). +- [ ] Uptime/health alerts ("your frame has been offline for 2 days"). + +### Ideas parking lot (unscheduled) + +- Fleet features: one cloud account administering many backends (installer / + digital-signage use case); cloud-side "all my frames" dashboard. +- Shared household access: invite a second cloud account to a backend with a + role (viewer/member/admin) — the `cloud_membership` table anticipates this. +- Notifications: deploy finished / frame offline → push/email via cloud. +- Community scene of the day / featured gallery pushed as an opt-in feed. +- Hosted backends: run the whole backend in the cloud, only frames at home. +- E-ink-friendly weather/calendar data proxy (normalized upstream APIs, one + key, cached) so users don't need their own API keys per service. + +## Protocol summary (details in docs/cloud-link.md) + +``` +POST {provider}/api/device/start → device_code, user_code, verification_uri(_complete), interval, expires_in +POST {provider}/api/device/poll → authorization_pending | access_token + token_reference + linked_client_id +POST {provider}/api/backends/inventory (Bearer) → report version/capabilities/health +GET {provider}/api/backends/grants (Bearer) → owning account, granted scopes +POST {provider}/api/backends/rotate-token (Bearer) → new token (atomic swap) +POST {provider}/api/device/revoke (Bearer) → unlink +POST {provider}/api/frameos/login/start (Bearer) → login handoff (Phase 1) +``` + +The provider URL is user-editable (default `https://cloud.frameos.net`), so any +server implementing this contract works. Env override: `FRAMEOS_CLOUD_URL` +(`disabled` hides the feature entirely). + +## Open questions + +- Billing mechanics (Stripe? bundled tiers vs. per-service metering) — decide + before Phase 3 ships anything paid. +- Should `store:publish` require a verified email + human review always, or + only for the public store (not personal collections)? +- Asset backup encryption UX: who holds the key, what does recovery look like + if the user loses it? (Answer must be "we cannot read your photos".) +- One backend link per installation vs. per project — currently one per + installation; multi-tenant installs may eventually want per-organization. diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 6e4b574e5..292aab6d6 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -23,6 +23,7 @@ from .apps import * # noqa: E402, F403 from .assets import * # noqa: E402, F403 from .chats import * # noqa: E402, F403 +from .cloud 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.py b/backend/app/api/cloud.py new file mode 100644 index 000000000..403f3b99a --- /dev/null +++ b/backend/app/api/cloud.py @@ -0,0 +1,314 @@ +"""Linking this FrameOS installation to a FrameOS Cloud provider. + +Phase 0 of CLOUD-TODO.md: establish and hold a scoped link token via the +OAuth 2.0 Device Authorization Grant. The protocol is documented in docs/cloud-link.md; the provider URL is +user-editable so any compatible server works. All connections are +outbound-only, initiated here. +""" +from __future__ import annotations + +import datetime +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.cloud import CloudBackendLink, current_cloud_backend_link, link_is_expired +from app.schemas.cloud import ( + CloudConnectRequest, + CloudProviderUpdateRequest, + CloudStatusResponse, +) +from app.utils import cloud_link as cloud +from app.utils.versions import current_frameos_version + +from . import api_user + +# Scopes a link may request; must stay in sync with the table in CLOUD-TODO.md +# and docs/cloud-link.md. +KNOWN_SCOPES = { + "backend:link", + "backend:read", + "auth:login", + "store:read", + "store:publish", + "gallery:read", + "backup:scenes", + "backup:frames", + "backup:assets", + "remote:access", + "telemetry:logs", + "telemetry:metrics", +} + + +def _now() -> datetime.datetime: + return datetime.datetime.utcnow() + + +def _request_origin(request: Request) -> str: + forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip() + forwarded_host = request.headers.get("x-forwarded-host", "").split(",", 1)[0].strip() + scheme = forwarded_proto or request.url.scheme + host = forwarded_host or request.headers.get("host") or request.url.netloc + return f"{scheme}://{host}".rstrip("/") + + +def _effective_provider_url(link: CloudBackendLink | None) -> str | None: + if link is not None and link.provider_url: + return link.provider_url + return cloud.default_cloud_provider_url() + + +def _status_payload(db: Session, link: CloudBackendLink | None) -> dict: + default_url = cloud.default_cloud_provider_url() + enabled = default_url is not None + if link is not None and link_is_expired(link, _now()): + _reset_link(link, poll_error="expired") + db.commit() + + status = link.status if link else "disconnected" + payload: dict = { + "enabled": enabled, + "provider_url": _effective_provider_url(link), + "default_provider_url": default_url, + "status": status, + "can_edit_provider": status == "disconnected", + "poll_error": link.poll_error if link else None, + "connection": None, + "link": None, + } + if link is None: + return payload + if status == "connecting": + payload["connection"] = { + "user_code": link.user_code, + "verification_uri": link.verification_uri, + "verification_uri_complete": link.verification_uri_complete, + "expires_at": link.expires_at.isoformat() if link.expires_at else None, + "interval_seconds": link.interval_seconds, + } + if status == "connected": + payload["link"] = { + "linked_client_id": link.linked_client_id, + "scopes": link.scopes, + "account_id": link.cloud_account_id, + "account_email": link.cloud_account_email, + "connected_at": link.updated_at.isoformat() if link.updated_at else None, + "last_inventory_sync_at": link.last_inventory_sync_at.isoformat() + if link.last_inventory_sync_at + else None, + } + return payload + + +def _reset_link(link: CloudBackendLink, poll_error: str | None = None) -> None: + link.status = "disconnected" + link.device_code = None + link.user_code = None + link.verification_uri = None + link.verification_uri_complete = None + link.expires_at = None + link.access_token = None + link.token_reference = None + link.linked_client_id = None + link.cloud_account_id = None + link.cloud_account_email = None + link.scope = None + link.poll_error = poll_error + link.revoked_at = None + link.updated_at = _now() + + +@api_user.get("/cloud/status", response_model=CloudStatusResponse) +async def get_cloud_status(db: Session = Depends(get_db)): + return _status_payload(db, current_cloud_backend_link(db)) + + +async def _update_provider(data: CloudProviderUpdateRequest, db: Session) -> dict: + try: + provider_url = cloud.normalize_cloud_provider_url(data.provider_url) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + if provider_url is None: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Enter a server URL") + + link = current_cloud_backend_link(db) + if link is not None and link.status != "disconnected": + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="Disconnect from FrameOS Cloud before changing the server URL", + ) + if link is None: + link = CloudBackendLink(provider_url=provider_url, status="disconnected") + db.add(link) + else: + link.provider_url = provider_url + link.poll_error = None + link.updated_at = _now() + db.commit() + return _status_payload(db, link) + + +@api_user.post("/cloud/provider", response_model=CloudStatusResponse) +async def set_cloud_provider(data: CloudProviderUpdateRequest, db: Session = Depends(get_db)): + return await _update_provider(data, db) + + +async def _start_connect(request: Request, data: CloudConnectRequest, db: Session) -> dict: + link = current_cloud_backend_link(db) + if link is not None and link.status == "connected": + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="Already connected to FrameOS Cloud") + + try: + provider_url = ( + cloud.normalize_cloud_provider_url(data.provider_url) + if data.provider_url + else _effective_provider_url(link) + ) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + if provider_url is None: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="FrameOS Cloud is disabled on this install") + + scopes = [s for s in (data.scopes or cloud.DEFAULT_LINK_SCOPES) if s in KNOWN_SCOPES] + if not scopes: + scopes = list(cloud.DEFAULT_LINK_SCOPES) + + origin = _request_origin(request) + start_payload = { + "public_display_name": f"FrameOS backend ({origin})", + "local_origin": origin, + "reported_frameos_version": current_frameos_version(), + "capabilities": {"localFallback": True}, + "client_kind": "backend", + "scopes": scopes, + } + try: + status_code, response = await cloud.device_start(provider_url, start_payload) + except Exception as exc: # noqa: BLE001 — network errors become a 502 with the cause + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {provider_url}: {exc}" + ) from exc + if status_code != 200 or not response.get("device_code"): + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud rejected the request: {detail}") + + if link is None: + link = CloudBackendLink(provider_url=provider_url) + db.add(link) + _reset_link(link) + link.provider_url = provider_url + link.status = "connecting" + link.public_display_name = start_payload["public_display_name"] + link.local_origin = origin + link.device_code = response["device_code"] + link.user_code = response.get("user_code") + link.verification_uri = response.get("verification_uri") + link.verification_uri_complete = response.get("verification_uri_complete") + link.interval_seconds = int(response.get("interval") or 5) + link.scope = " ".join(scopes) + expires_in = response.get("expires_in") + if expires_in: + link.expires_at = _now() + datetime.timedelta(seconds=int(expires_in)) + db.commit() + return _status_payload(db, link) + + +@api_user.post("/cloud/connect", response_model=CloudStatusResponse) +async def connect_cloud(request: Request, data: CloudConnectRequest, db: Session = Depends(get_db)): + return await _start_connect(request, data, db) + + +async def _poll_link(db: Session) -> dict: + link = current_cloud_backend_link(db) + if link is None or link.status != "connecting" or not link.device_code: + return _status_payload(db, link) + + try: + status_code, response = await cloud.device_poll(link.provider_url, link.device_code) + except Exception: # noqa: BLE001 — transient network errors keep the flow alive + link.poll_error = "network_error" + db.commit() + return _status_payload(db, link) + + error = response.get("error") + if error == "authorization_pending": + link.poll_error = None + db.commit() + return _status_payload(db, link) + if status_code == 200 and response.get("access_token"): + link.status = "connected" + link.access_token = cloud.encrypt_cloud_secret(response["access_token"]) + link.token_reference = response.get("token_reference") + link.linked_client_id = response.get("linked_client_id") + if response.get("scope"): + link.scope = response["scope"] + link.device_code = None + link.user_code = None + link.verification_uri = None + link.verification_uri_complete = None + link.expires_at = None + link.poll_error = None + link.updated_at = _now() + db.commit() + await _sync_after_connect(db, link, response["access_token"]) + return _status_payload(db, link) + + # denied / expired / anything else: back to square one with the reason kept + _reset_link(link, poll_error=error or f"unexpected status {status_code}") + db.commit() + return _status_payload(db, link) + + +@api_user.post("/cloud/poll", response_model=CloudStatusResponse) +async def poll_cloud(db: Session = Depends(get_db)): + return await _poll_link(db) + + +async def _sync_after_connect(db: Session, link: CloudBackendLink, access_token: str) -> None: + """Best effort: report inventory and learn which account owns us.""" + try: + status_code, _ = await cloud.backend_inventory( + link.provider_url, + access_token, + { + "reported_frameos_version": current_frameos_version(), + "capabilities": {"localFallback": True}, + "health": {"status": "ok"}, + }, + ) + if status_code == 200: + link.last_inventory_sync_at = _now() + except Exception: # noqa: BLE001 + pass + try: + status_code, response = await cloud.backend_grants(link.provider_url, access_token) + if status_code == 200: + grants = response.get("grants") or [] + owner = next((g for g in grants if isinstance(g, dict) and g.get("role") == "owner"), None) + if owner: + link.cloud_account_id = owner.get("account_id") + link.cloud_account_email = owner.get("account_email") + link.last_grant_sync_at = _now() + except Exception: # noqa: BLE001 + pass + db.commit() + + +@api_user.post("/cloud/disconnect", response_model=CloudStatusResponse) +async def disconnect_cloud(db: Session = Depends(get_db)): + link = current_cloud_backend_link(db) + if link is None: + return _status_payload(db, None) + + access_token = cloud.decrypt_cloud_secret(link.access_token) + if link.status == "connected" and access_token: + try: + await cloud.backend_unlink(link.provider_url, access_token) + except Exception: # noqa: BLE001 — local disconnect must work while the cloud is down + pass + _reset_link(link) + db.commit() + return _status_payload(db, link) diff --git a/backend/app/api/tests/test_cloud.py b/backend/app/api/tests/test_cloud.py new file mode 100644 index 000000000..abedd322f --- /dev/null +++ b/backend/app/api/tests/test_cloud.py @@ -0,0 +1,208 @@ +import pytest + +from app.models.cloud import CloudBackendLink +from app.utils import cloud_link + + +PROVIDER = "https://cloud.frameos.net" + +START_RESPONSE = { + "device_code": "device-code-1", + "user_code": "ABCD-1234", + "verification_uri": f"{PROVIDER}/device", + "verification_uri_complete": f"{PROVIDER}/device?code=ABCD-1234", + "expires_in": 600, + "interval": 5, +} + +POLL_SUCCESS = { + "access_token": "link-token-secret", + "approved_by": { + "account_id": "acc-1", + "email": "owner@example.com", + "email_verified": True, + "name": "Owner", + "provider_issuer": PROVIDER, + "provider_subject": "subject-1", + "sub": "subject-1", + }, + "linked_client_id": "lc-1", + "scope": "backend:link backend:read", + "token_reference": "tokref-1", + "token_type": "Bearer", +} + +GRANTS_RESPONSE = { + "grants": [{"account_id": "acc-1", "account_email": "owner@example.com", "role": "owner"}], + "linked_client_id": "lc-1", +} + + +@pytest.fixture +def cloud_calls(monkeypatch): + calls = {"start": [], "poll": [], "inventory": [], "grants": [], "unlink": []} + responses = { + "start": (200, START_RESPONSE), + "poll": (428, {"error": "authorization_pending", "interval": 5}), + "inventory": (200, {"status": "ok"}), + "grants": (200, GRANTS_RESPONSE), + "unlink": (200, {"status": "unlinked"}), + } + + def make(name): + async def call(*args, **kwargs): + calls[name].append((args, kwargs)) + result = responses[name] + if isinstance(result, Exception): + raise result + return result + return call + + monkeypatch.setattr(cloud_link, "device_start", make("start")) + monkeypatch.setattr(cloud_link, "device_poll", make("poll")) + monkeypatch.setattr(cloud_link, "backend_inventory", make("inventory")) + monkeypatch.setattr(cloud_link, "backend_grants", make("grants")) + monkeypatch.setattr(cloud_link, "backend_unlink", make("unlink")) + return calls, responses + + +@pytest.mark.asyncio +async def test_status_defaults_to_disconnected(async_client): + response = await async_client.get("/api/cloud/status") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "disconnected" + assert data["enabled"] is True + assert data["provider_url"] == PROVIDER + assert data["can_edit_provider"] is True + assert data["link"] is None + assert data["connection"] is None + + +@pytest.mark.asyncio +async def test_status_requires_login(db): + from httpx import AsyncClient + from httpx._transports.asgi import ASGITransport + from app.fastapi import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.get("/api/cloud/status") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_set_provider_url(async_client): + response = await async_client.post("/api/cloud/provider", json={"provider_url": "https://my.cloud.example/"}) + assert response.status_code == 200 + assert response.json()["provider_url"] == "https://my.cloud.example" + + response = await async_client.post("/api/cloud/provider", json={"provider_url": "not a url"}) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_connect_starts_device_flow(async_client, cloud_calls): + calls, _ = cloud_calls + response = await async_client.post("/api/cloud/connect", json={}) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "connecting" + assert data["connection"]["user_code"] == "ABCD-1234" + assert data["connection"]["verification_uri_complete"] == f"{PROVIDER}/device?code=ABCD-1234" + assert data["can_edit_provider"] is False + + (args, _kwargs) = calls["start"][0] + provider_url, payload = args + assert provider_url == PROVIDER + assert payload["scopes"] == [ + "backend:link", + "backend:read", + "backup:scenes", + "backup:frames", + "store:publish", + ] + assert payload["local_origin"].startswith("http://") + + +@pytest.mark.asyncio +async def test_connect_filters_unknown_scopes(async_client, cloud_calls): + calls, _ = cloud_calls + response = await async_client.post( + "/api/cloud/connect", json={"scopes": ["backend:link", "evil:scope", "auth:login"]} + ) + assert response.status_code == 200 + (args, _kwargs) = calls["start"][0] + assert args[1]["scopes"] == ["backend:link", "auth:login"] + + +@pytest.mark.asyncio +async def test_poll_pending_then_connected(async_client, cloud_calls, db): + _calls, responses = cloud_calls + await async_client.post("/api/cloud/connect", json={}) + + response = await async_client.post("/api/cloud/poll") + assert response.json()["status"] == "connecting" + + responses["poll"] = (200, POLL_SUCCESS) + response = await async_client.post("/api/cloud/poll") + data = response.json() + assert data["status"] == "connected" + assert data["link"]["linked_client_id"] == "lc-1" + assert data["link"]["scopes"] == ["backend:link", "backend:read"] + assert data["link"]["account_email"] == "owner@example.com" + assert data["connection"] is None + assert "access_token" not in str(data) + + link = db.query(CloudBackendLink).first() + assert link.access_token is not None + assert "link-token-secret" not in link.access_token + assert cloud_link.decrypt_cloud_secret(link.access_token) == "link-token-secret" + assert link.device_code is None + + +@pytest.mark.asyncio +async def test_poll_denied_resets_link(async_client, cloud_calls): + _calls, responses = cloud_calls + await async_client.post("/api/cloud/connect", json={}) + responses["poll"] = (403, {"error": "access_denied"}) + response = await async_client.post("/api/cloud/poll") + data = response.json() + assert data["status"] == "disconnected" + assert data["poll_error"] == "access_denied" + + +@pytest.mark.asyncio +async def test_disconnect_unlinks_and_resets(async_client, cloud_calls, db): + calls, responses = cloud_calls + await async_client.post("/api/cloud/connect", json={}) + responses["poll"] = (200, POLL_SUCCESS) + await async_client.post("/api/cloud/poll") + + response = await async_client.post("/api/cloud/disconnect") + data = response.json() + assert data["status"] == "disconnected" + assert len(calls["unlink"]) == 1 + (args, kwargs) = calls["unlink"][0] + assert args == (PROVIDER, "link-token-secret") + + link = db.query(CloudBackendLink).first() + assert link.access_token is None + assert link.linked_client_id is None + + +@pytest.mark.asyncio +async def test_cannot_change_provider_while_connecting(async_client, cloud_calls): + await async_client.post("/api/cloud/connect", json={}) + response = await async_client.post("/api/cloud/provider", json={"provider_url": "https://other.example"}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_connect_unreachable_provider(async_client, cloud_calls, monkeypatch): + async def boom(*_args, **_kwargs): + raise RuntimeError("connection refused") + + monkeypatch.setattr(cloud_link, "device_start", boom) + response = await async_client.post("/api/cloud/connect", json={}) + assert response.status_code == 502 diff --git a/backend/app/config.py b/backend/app/config.py index 52a8ee5ef..7c9bc1b71 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,6 +45,10 @@ class Config: DATABASE_URL = os.environ.get('DATABASE_URL') or 'sqlite:///../db/frameos.db' REDIS_URL = os.environ.get('REDIS_URL') or 'redis://localhost:6379/0' INSTANCE_ID = INSTANCE_ID + # FrameOS Cloud provider origin. Empty = https://cloud.frameos.net, + # any http(s) URL = a compatible self-hosted provider, 'disabled' = hide + # the cloud link entirely. See docs/cloud-link.md. + FRAMEOS_CLOUD_URL = os.environ.get('FRAMEOS_CLOUD_URL') or os.environ.get('FRAMEOS_AUTH_PROVIDER_URL') or '' HASSIO_RUN_MODE = os.environ.get('HASSIO_RUN_MODE', None) HASSIO_TOKEN = os.environ.get('HASSIO_TOKEN', None) SUPERVISOR_TOKEN = os.environ.get('SUPERVISOR_TOKEN', None) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 106735fa6..b3ef44d20 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from .apps import * # noqa: F403 from .assets import * # noqa: F403 from .chat import * # noqa: F403 +from .cloud import * # noqa: F403 from .frame import * # noqa: F403 from .log import * # noqa: F403 from .metrics import * # noqa: F403 diff --git a/backend/app/models/cloud.py b/backend/app/models/cloud.py new file mode 100644 index 000000000..7c2836a90 --- /dev/null +++ b/backend/app/models/cloud.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Session, mapped_column, relationship + +from app.database import Base + + +class CloudIdentity(Base): + """A local user linked to a FrameOS Cloud account (used from Phase 1 on).""" + + __tablename__ = "cloud_identity" + __table_args__ = ( + UniqueConstraint("provider_issuer", "provider_subject", name="uq_cloud_identity_provider_subject"), + ) + + id = mapped_column(Integer, primary_key=True) + user_id = mapped_column(Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True) + provider_url = mapped_column(String(512), nullable=False) + provider_issuer = mapped_column(String(512), nullable=False) + provider_subject = mapped_column(String(512), nullable=False) + cloud_account_id = mapped_column(String(128), nullable=True, index=True) + email = mapped_column(String(256), nullable=True) + email_verified = mapped_column(Boolean, nullable=False, default=False) + name = mapped_column(String(256), nullable=True) + last_login_at = mapped_column(DateTime, nullable=True) + created_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + updated_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + + user = relationship("User") + + +class CloudBackendLink(Base): + """This installation's link to a FrameOS Cloud provider. One row per install.""" + + __tablename__ = "cloud_backend_link" + + id = mapped_column(Integer, primary_key=True) + provider_url = mapped_column(String(512), nullable=False) + provider_issuer = mapped_column(String(512), nullable=True) + status = mapped_column(String(32), nullable=False, default="disconnected") + public_display_name = mapped_column(String(256), nullable=True) + local_origin = mapped_column(String(512), nullable=True) + device_code = mapped_column(String(2048), nullable=True) + user_code = mapped_column(String(64), nullable=True) + verification_uri = mapped_column(String(1024), nullable=True) + verification_uri_complete = mapped_column(String(1024), nullable=True) + expires_at = mapped_column(DateTime, nullable=True) + interval_seconds = mapped_column(Integer, nullable=False, default=5) + poll_error = mapped_column(String(128), nullable=True) + # Encrypted with Fernet keyed off SECRET_KEY; never returned by the API. + access_token = mapped_column(String(4096), nullable=True) + token_reference = mapped_column(String(256), nullable=True) + linked_client_id = mapped_column(String(128), nullable=True) + cloud_account_id = mapped_column(String(128), nullable=True) + cloud_account_email = mapped_column(String(256), nullable=True) + cloud_organization_id = mapped_column(String(128), nullable=True) + cloud_project_id = mapped_column(String(128), nullable=True) + scope = mapped_column(String(1024), nullable=True) + local_organization_id = mapped_column(Integer, ForeignKey("organization.id", ondelete="SET NULL"), nullable=True) + local_project_id = mapped_column(Integer, ForeignKey("project.id", ondelete="SET NULL"), nullable=True) + local_fallback_enabled = mapped_column(Boolean, nullable=False, default=True) + last_inventory_sync_at = mapped_column(DateTime, nullable=True) + last_grant_sync_at = mapped_column(DateTime, nullable=True) + revoked_at = mapped_column(DateTime, nullable=True) + created_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + updated_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + + memberships = relationship("CloudMembership", back_populates="backend_link", cascade="all, delete-orphan") + + @property + def scopes(self) -> list[str]: + return (self.scope or "").split() if self.scope else [] + + +class CloudMembership(Base): + """Cloud-side access grants synced onto this backend (used from Phase 1 on).""" + + __tablename__ = "cloud_membership" + __table_args__ = ( + UniqueConstraint( + "backend_link_id", + "cloud_account_id", + "cloud_organization_id", + "cloud_project_id", + name="uq_cloud_membership_grant", + ), + ) + + id = mapped_column(Integer, primary_key=True) + backend_link_id = mapped_column(Integer, ForeignKey("cloud_backend_link.id", ondelete="CASCADE"), nullable=False) + cloud_account_id = mapped_column(String(128), nullable=False, index=True) + cloud_organization_id = mapped_column(String(128), nullable=False) + cloud_project_id = mapped_column(String(128), nullable=True) + role = mapped_column(String(32), nullable=False) + local_organization_id = mapped_column(Integer, ForeignKey("organization.id", ondelete="SET NULL"), nullable=True) + local_project_id = mapped_column(Integer, ForeignKey("project.id", ondelete="SET NULL"), nullable=True) + updated_at = mapped_column(DateTime, nullable=True) + synced_at = mapped_column(DateTime, nullable=False, default=func.current_timestamp()) + + backend_link = relationship("CloudBackendLink", back_populates="memberships") + + +def current_cloud_backend_link(db: Session) -> CloudBackendLink | None: + return db.query(CloudBackendLink).order_by(CloudBackendLink.id.desc()).first() + + +def link_is_expired(link: CloudBackendLink, now: datetime) -> bool: + return link.status == "connecting" and link.expires_at is not None and link.expires_at <= now diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py new file mode 100644 index 000000000..6d8dd5b7d --- /dev/null +++ b/backend/app/schemas/cloud.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, RootModel + + +class CloudStatusResponse(RootModel): + pass + + +class CloudConnectRequest(BaseModel): + provider_url: str | None = None + scopes: list[str] | None = None + + +class CloudProviderUpdateRequest(BaseModel): + provider_url: str diff --git a/backend/app/utils/cloud_link.py b/backend/app/utils/cloud_link.py new file mode 100644 index 000000000..e90145467 --- /dev/null +++ b/backend/app/utils/cloud_link.py @@ -0,0 +1,147 @@ +"""Client helpers for the FrameOS Cloud link protocol. + +The protocol is documented in docs/cloud-link.md. It is a plain OAuth 2.0 +Device Authorization Grant (RFC 8628) against a user-configurable provider, +so any server implementing the documented contract works — not just +cloud.frameos.net. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +from typing import Any +from urllib.parse import urlparse + +import httpx +from cryptography.fernet import Fernet, InvalidToken + +from app.config import config + +DEFAULT_CLOUD_PROVIDER_URL = "https://cloud.frameos.net" + +# Scopes requested by default when linking a backend: the link itself plus the +# features included with every cloud account (backups, saving and sharing +# scenes). Security-sensitive scopes (auth:login, remote:access, ...) are only +# requested later, when the user explicitly toggles the matching feature on. +DEFAULT_LINK_SCOPES = [ + "backend:link", + "backend:read", + "backup:scenes", + "backup:frames", + "store:publish", +] + +REQUEST_TIMEOUT_SECONDS = 15.0 + + +def normalize_cloud_provider_url(value: str | None) -> str | None: + """Return a normalized origin URL, None when disabled, raise on garbage.""" + normalized = (value or "").strip() + if normalized.lower() == "disabled": + return None + if not normalized: + return DEFAULT_CLOUD_PROVIDER_URL + parsed = urlparse(normalized) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError("The FrameOS Cloud server must be an http(s) URL") + path = parsed.path.rstrip("/") + return parsed._replace(path=path, params="", query="", fragment="").geturl().rstrip("/") + + +def default_cloud_provider_url() -> str | None: + """The provider URL from the environment, None when cloud is disabled.""" + return normalize_cloud_provider_url(config.FRAMEOS_CLOUD_URL) + + +def _cloud_fernet() -> Fernet: + digest = hashlib.sha256(config.SECRET_KEY.encode()).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt_cloud_secret(value: str | None) -> str | None: + if not value: + return None + return _cloud_fernet().encrypt(value.encode()).decode() + + +def decrypt_cloud_secret(value: str | None) -> str | None: + if not value: + return None + try: + return _cloud_fernet().decrypt(value.encode()).decode() + except (InvalidToken, UnicodeDecodeError): + return None + + +def cloud_api_url(provider_url: str, path: str) -> str: + return f"{provider_url.rstrip('/')}/{path.lstrip('/')}" + + +async def cloud_request( + method: str, + provider_url: str, + path: str, + *, + access_token: str | None = None, + json_body: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + """One JSON request to the cloud provider. Returns (status_code, payload).""" + headers = {"accept": "application/json"} + if access_token: + headers["authorization"] = f"Bearer {access_token}" + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client: + response = await client.request( + method, + cloud_api_url(provider_url, path), + headers=headers, + json=json_body, + ) + try: + payload = response.json() + except json.JSONDecodeError: + payload = {} + return response.status_code, payload if isinstance(payload, dict) else {} + + +async def device_start(provider_url: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + return await cloud_request("POST", provider_url, "/api/device/start", json_body=payload) + + +async def device_poll(provider_url: str, device_code: str) -> tuple[int, dict[str, Any]]: + return await cloud_request("POST", provider_url, "/api/device/poll", json_body={"device_code": device_code}) + + +async def backend_inventory( + provider_url: str, access_token: str, payload: dict[str, Any] +) -> tuple[int, dict[str, Any]]: + return await cloud_request( + "POST", provider_url, "/api/backends/inventory", access_token=access_token, json_body=payload + ) + + +async def backend_grants(provider_url: str, access_token: str) -> tuple[int, dict[str, Any]]: + return await cloud_request("GET", provider_url, "/api/backends/grants", access_token=access_token) + + +async def backend_unlink(provider_url: str, access_token: str) -> tuple[int, dict[str, Any]]: + return await cloud_request( + "POST", provider_url, "/api/backends/unlink", access_token=access_token, json_body={} + ) + + +async def backend_rotate_token(provider_url: str, access_token: str) -> tuple[int, dict[str, Any]]: + return await cloud_request( + "POST", provider_url, "/api/backends/rotate-token", access_token=access_token, json_body={} + ) + + +async def backend_set_scopes( + provider_url: str, access_token: str, scopes: list[str] +) -> tuple[int, dict[str, Any]]: + """Change the link's enabled features in place. Removals apply directly + ("status": "updated"); additions come back as "approval_required" with a + device code to poll while the owner approves on the provider.""" + return await cloud_request( + "POST", provider_url, "/api/backends/scopes", access_token=access_token, json_body={"scopes": scopes} + ) diff --git a/backend/migrations/versions/e3a1b5c7d9f2_cloud_link_account_fields.py b/backend/migrations/versions/e3a1b5c7d9f2_cloud_link_account_fields.py new file mode 100644 index 000000000..fb84a757a --- /dev/null +++ b/backend/migrations/versions/e3a1b5c7d9f2_cloud_link_account_fields.py @@ -0,0 +1,27 @@ +"""cloud link account fields + +Revision ID: e3a1b5c7d9f2 +Revises: c7e1a9f3d2b4 +Create Date: 2026-07-09 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = "e3a1b5c7d9f2" +down_revision = "c7e1a9f3d2b4" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("cloud_backend_link") as batch_op: + batch_op.add_column(sa.Column("cloud_account_id", sa.String(length=128), nullable=True)) + batch_op.add_column(sa.Column("cloud_account_email", sa.String(length=256), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("cloud_backend_link") as batch_op: + batch_op.drop_column("cloud_account_email") + batch_op.drop_column("cloud_account_id") diff --git a/backend/requirements.docker.in b/backend/requirements.docker.in index c21cd93d1..50f1e832f 100644 --- a/backend/requirements.docker.in +++ b/backend/requirements.docker.in @@ -6,9 +6,11 @@ aiomqtt alembic arq asyncssh +cryptography email_validator fastapi[standard] fonttools +httpx jwt modal>=1.4.0 openai diff --git a/backend/requirements.in b/backend/requirements.in index 8f3ee193b..ad7734c6e 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -3,10 +3,12 @@ aiomqtt alembic arq asyncssh +cryptography email_validator fastapi[standard] fonttools honcho +httpx jwt mypy modal>=1.4.0 diff --git a/docs/cloud-link.md b/docs/cloud-link.md new file mode 100644 index 000000000..4af906809 --- /dev/null +++ b/docs/cloud-link.md @@ -0,0 +1,482 @@ +# FrameOS Cloud link protocol + +FrameOS (AGPL) can link a backend — or a frame directly — to a "cloud +provider": by default `https://cloud.frameos.net`, but the URL is +user-editable and the protocol below is the complete contract, so anyone can +run their own compatible provider. FrameOS works fully without any provider; +the link only adds optional services. + +Related reading: `CLOUD-TODO.md` (roadmap and permission scopes), +`backend/app/api/cloud.py` and `backend/app/utils/cloud_link.py` (backend +implementation), `frameos/src/frameos/server/routes/cloud_api_routes.nim` +(on-frame implementation). + +## Principles + +- **Outbound-only.** The FrameOS side initiates every request. A provider can + never reach into an installation; even revocation only takes effect when the + installation next syncs. +- **Scoped tokens.** Linking uses the OAuth 2.0 Device Authorization Grant + (RFC 8628). The user approves a short code in their provider account, in a + browser, and sees exactly which permission scopes are requested. The + resulting bearer token carries only those scopes. +- **Local-first.** Local login and local data always keep working. Disabling + local password login (a later phase) will require a verified working cloud + session first. + +## Configuration + +Environment variable on the backend (`backend/app/config.py`): + +| `FRAMEOS_CLOUD_URL` | Meaning | +|---|---| +| unset / empty | use `https://cloud.frameos.net` | +| an `http(s)://` origin | use that provider | +| `disabled` | hide the cloud link feature entirely | + +(`FRAMEOS_AUTH_PROVIDER_URL` is accepted as a fallback name.) The provider URL +can also be edited in the UI while disconnected; the edited value is stored +with the link and wins over the environment default. + +On a frame, link state (including the URL) lives in `./state/cloud_link.json` +next to the FrameOS binary; there is no environment toggle. + +## Permission scopes + +Requested at link time, shown on the provider's consent screen, and returned +with the token. Unknown scopes must be dropped by the provider; an empty list +falls back to the provider's default. + +| Scope | Allows the holder to | +|---|---| +| `backend:link` | register a backend, sync inventory/health, rotate its token | +| `backend:read` | read basic backend connection details | +| `frame:link` | register a frame that links directly, without a backend | +| `auth:login` | sign users in to this installation via their cloud account | +| `store:read` | browse/install from scene & app repositories | +| `store:publish` | publish scenes/apps to the user's collections or the store | +| `gallery:read` | access curated photo galleries | +| `backup:scenes` | store scene template collections | +| `backup:frames` | store frame metadata + scene backups | +| `backup:assets` | store client-side-encrypted frame asset backups | +| `remote:access` | relay inbound connections to this installation | +| `telemetry:logs` | ship logs for retention | +| `telemetry:metrics` | ship metrics for retention | + +Backends link with the base scopes plus the features included with every +cloud account (`backup:scenes`, `backup:frames`, `store:publish`) in one +approval; frames link with `frame:link` (+ `auth:login`). Security-sensitive +scopes (`auth:login`, `remote:access`, telemetry) are only requested when the +user explicitly toggles the matching feature on. Some scopes may map to paid +plans on cloud.frameos.net; the consent screen must say so before approval. + +## Linking (device authorization) + +All bodies are JSON; all responses are JSON. + +### 1. Start + +```http +POST {provider}/api/device/start +``` + +```json +{ + "public_display_name": "FrameOS backend (https://frameos.example)", + "local_origin": "https://frameos.example", + "reported_frameos_version": "2026.7.4", + "capabilities": { "localFallback": true }, + "client_kind": "backend", + "scopes": ["backend:link", "backend:read"] +} +``` + +`client_kind` is `"backend"` or `"frame"`; when omitted, the provider derives +it from the base scope (`frame:link` → frame). It is shown on the consent +screen and stored with the link. + +Response `200`: + +```json +{ + "device_code": "…", + "user_code": "ABCD-1234", + "verification_uri": "https://cloud.frameos.net/device", + "verification_uri_complete": "https://cloud.frameos.net/device?code=ABCD-1234", + "expires_in": 600, + "interval": 5 +} +``` + +The FrameOS UI shows `user_code` and links to `verification_uri_complete`. +The user signs in to the provider and approves (or denies) the request there, +seeing the requested scopes. + +### 2. Poll + +```http +POST {provider}/api/device/poll +{ "device_code": "…" } +``` + +- Pending: `{"error": "authorization_pending", "interval": 5}` (HTTP 428) +- Denied: `{"error": "access_denied"}` (HTTP 403) +- Expired: `{"error": "expired_token"}` (HTTP 400) +- Approved (once — the device code is single-use): + +```json +{ + "access_token": "…", + "token_type": "Bearer", + "scope": "backend:link backend:read", + "linked_client_id": "…", + "token_reference": "…", + "approved_by": { + "account_id": "…", + "email": "owner@example.com", + "email_verified": true, + "name": "…", + "provider_issuer": "…", + "provider_subject": "…", + "sub": "…" + } +} +``` + +`approved_by` identifies the account that approved the link, in the same +claim format as the login handoff. Since the approver is the person doing the +connecting, FrameOS maps it to the connecting local user right away +(`cloud_identity`), so cloud login works without a separate linking step. It +is released once, with the token. + +The FrameOS side stores the token encrypted at rest (backend: Fernet keyed off +`SECRET_KEY`; frame: `0600` state file) and never exposes it over its own API. + +## Linked endpoints (Bearer token) + +```http +POST {provider}/api/backends/inventory # report version/capabilities/health +GET {provider}/api/backends/grants # who owns this link + granted scopes +POST {provider}/api/backends/rotate-token # atomic credential rotation +POST {provider}/api/backends/scopes # change enabled features in place +POST {provider}/api/backends/unlink # self-revoke on disconnect +``` + +### Changing enabled features (`/api/backends/scopes`) + +`{"scopes": ["backend:link", "backend:read", "auth:login", …]}` — the full +desired set. Removing scopes is applied immediately (`{"status": "updated", +"scope": "…"}`): the token holder reducing its own privileges needs no +consent, and the base link scope can never be dropped. Adding a scope that +comes with every cloud account (`backup:scenes`, `backup:frames`, +`store:publish`) is also applied immediately. Adding a security-sensitive +scope (`auth:login`, `remote:access`, …) returns +`{"status": "approval_required", "device_code", "user_code", +"verification_uri(_complete)", "expires_in", "interval"}`: the owner approves +the change on the provider's device screen (only the account that owns the +link may approve it), and the FrameOS side polls `POST /api/device/poll` as +usual. The approved poll response carries the new `scope` and **no** +`access_token` — the link credential never changes. + +`grants` response shape: + +```json +{ + "grants": [ + { "account_id": "…", "account_email": "owner@example.com", "role": "owner", "updated_at": "…" } + ], + "linked_client_id": "…" +} +``` + +`account_email` is a display snapshot, not an identity key. FrameOS may cache +grant state across short provider outages, but must honor revocation as soon +as it can reconnect (an unlinked token gets `401 invalid_link_token`). The +backend runs a periodic grants sync (`backend/app/cloud/sync.py`); a 401 +resets the local link and re-enables local password login. + +## Login handoff (Phase 1, scope `auth:login`) + +Signs a browser user in to a FrameOS install with their provider account. The +provider only completes a handoff for the account that owns the link, so a +redeemed code is proof of ownership. Flow (all provider calls carry the link's +Bearer token and require the `auth:login` scope, else `403 insufficient_scope`): + +```http +POST {provider}/api/frameos/login/start +{ "redirect_uri": "{local_origin}/api/cloud/login/callback", "state": "…", "intent": "login" } +``` + +`redirect_uri` must be on the `local_origin` reported at link time. Response: +`{"authorization_url": "…", "expires_in": 600}`. FrameOS sends the browser to +`authorization_url`; the provider authenticates the user, checks they own the +link, and 30x-redirects to `redirect_uri?code=…&state=…` (or `?error=…&state=…`). + +```http +POST {provider}/api/frameos/login/token +{ "code": "…" } +``` + +The code is single-use and bound to the linked client. Response: + +```json +{ + "claims": { "account_id": "…", "email": "…", "email_verified": true, "name": "…", "provider_subject": "…", "sub": "…" }, + "provider_issuer": "…" +} +``` + +FrameOS-side behavior (`backend/app/api/cloud.py`, frame: +`cloud_api_routes.nim`): + +- Identity mapping is keyed on `(provider_issuer, provider_subject)` and stored + in `cloud_identity`. A matching email is never proof of ownership: an + existing local user must link their cloud account explicitly (logged-in + handoff via `POST /api/cloud/identity/link`). +- First-run setup: while no local user exists, the open `/api/cloud/setup/*` + endpoints mirror status/provider/connect/poll/disconnect, and a completed + login handoff creates the first user from the cloud principal. +- Local fallback: `POST /api/cloud/local-fallback {"enabled": false}` disables + local password login. It requires a connected link with `auth:login`, the + current user's identity matching the link's owner account, and a live grants + check. Losing or disconnecting the link always re-enables local login. +- Frames run the same handoff against their own open + `/api/cloud/login/{options,start,callback}` routes; a successful callback + mints the on-device admin session. +- Logout: signing out of a FrameOS install that uses cloud login also ends the + provider session, or the login screen's cloud button would sign the user + straight back in. `POST /api/logout` returns a `cloud_logout_url` + (`{provider}/logout?return_to={origin}/login`) when the user has a linked + identity; the provider validates `return_to` against the account's linked + client origins (loopback hosts are allowed for development) and bounces + back to the install's login page. + +## Config backups (Phase 3, scopes `backup:scenes` / `backup:frames`) + +Small replace-in-place blobs owned by the provider **account** (not the linked +client), so a reinstalled backend that relinks to the same account can restore +them. All endpoints carry the link's Bearer token and enforce the matching +scope per kind (`templates` → `backup:scenes`, `frames` → `backup:frames`): + +```http +GET {provider}/api/backends/backups # list (kinds the scopes allow) +POST {provider}/api/backends/backups # save/replace one blob +GET {provider}/api/backends/backups/{id} # metadata + content_base64 +DELETE {provider}/api/backends/backups/{id} +``` + +Save request: + +```json +{ + "kind": "frames", + "item_key": "frame-7", + "name": "Kitchen frame", + "content_base64": "…", + "content_type": "application/json" +} +``` + +One live copy exists per `(account, kind, item_key)`; a new save replaces it. +Providers should cap blob size (cloud.frameos.net: 8 MB) and count per +account, and answer `413 backup_too_large` / `403 backup_quota_exceeded`. + +Payload formats (defined FrameOS-side, opaque to the provider): + +- `templates`: the scene/template interchange zip (`{name}/template.json`, + `scenes.json`, `image.jpg`) — the same file the local export produces. (The + kind string predates the templates→scenes rename; the scope is + `backup:scenes` and the UI says "scene".) +- `frames`: JSON `{"format": "frameos-frame-backup-v1", "saved_at", "project_name", "frame": {…}}` + where `frame` is the frame's metadata + scene JSON **with all local secrets + stripped** (SSH credentials, access keys, TLS material, wifi passwords — + see `backend/app/utils/cloud_backup.py`). Restores regenerate fresh local + credentials. Frame backups are pushed automatically after each successful + deploy while the scope is granted **and** the local switch is on. + +The scopes are granted with the link, but they are a permission only: FrameOS +uploads nothing until the user turns the matching backup switch on (Settings → +FrameOS Cloud; `backup_scenes_enabled` / `backup_frames_enabled` on the link, +`POST /api/cloud/backup-features` locally). Connecting alone never sends data. + +The do-it-yourself alternative that needs no provider: `GET /api/backup/export` +on the backend returns everything (full fidelity, secrets included — it stays +local) as a plain `.tar.gz`. + +## Scene store (Phase 2, scope `store:publish`) + +The provider may host an npm-style registry of scenes. Distribution reuses the +formats FrameOS already speaks, so **browsing and installing needs no new +protocol at all**: the public store is a plain scenes repository — + +```http +GET {provider}/api/store/repository.json # public, standard repository JSON +GET {provider}/api/store/scenes/{id}/download # public; ?version=N; the template zip +GET {provider}/api/store/scenes/{id}/image # public; preview image +``` + +Repository entries may carry extra fields older installs simply ignore: +`author` (publisher display name, rendered as "by {name}" in the Templates +panel), `flags` (risk flags computed at publish, e.g. `["shell"]` for +scenes that configure shell-running apps or code — the UI badges these and +asks for confirmation before install), and `frameosVersion` (the FrameOS +release the scene was exported with — the backend stamps it into +`template.json` at export, the provider reads it from there; the UI shows +"newer than this install" as an upgrade nudge when applicable). + +The provider may also serve the extracted scenes JSON of a scene's latest +version for in-browser live previews (same access rules as the download): + +```http +GET {provider}/api/store/scenes/{id}/scenes.json +``` + +**Install by pasting a page URL.** Scene pages advertise their zip in a meta +tag, so people can copy a scene page's URL into the search box when adding a +new scene and install from there: + +```html + +``` + +FrameOS' `POST /api/templates {url}` accepts any URL: if the response is not +a zip, it resolves `frameos:zip` from the HTML and fetches that (attaching +the link token for provider-host URLs, so the owner's private scene pages +work too — the provider lets the owner's linked backend fetch them with the +Bearer token). + +frameos.net's website runs these previews with the +[`frameos-wasm`](https://www.npmjs.com/package/frameos-wasm) npm package +(built from `frameos/wasm` in this repo; its version always equals the +FrameOS release the runtime was built from). + +A backend with a connected link seeds `{provider}/api/store/repository.json` +as a normal repository once per project (deleting it is respected). + +**My cloud drive** — the account's own scenes, private ones included — is the +same repository format behind the link token: + +```http +GET {provider}/api/store/account/repository.json # Bearer + store:publish +``` + +Entries use absolute `image`/`zip` URLs plus a `sceneId` and `visibility` +field. Private downloads/images accept the owner's link token, so the backend +proxies the listing and preview images for the browser +(`GET /api/cloud/store/drive`, `GET /api/cloud/store/drive/image/{sceneId}`) +and attaches the token when fetching template zips from the provider host +(and only from the provider host — it never leaks to other repositories). + +Publishing carries the link's Bearer token and the `store:publish` scope: + +```http +POST {provider}/api/store/publish +``` + +```json +{ + "name": "Sunrise Clock", + "description": "optional; falls back to the zip's template.json", + "visibility": "private | public — optional; private on first publish, unchanged after", + "content_base64": "…the template interchange zip…", + "content_type": "application/zip" +} +``` + +Response: `{"status": "published", "scene": {"id", "slug", "name", +"visibility", "version", "url"}}` — `url` is the scene's page on the +provider's website. + +Semantics the provider must honor: + +- **Versions are immutable.** A publish appends version N+1; re-publishing the + same `name` from the same account updates that scene, a new name creates a + new one. Bytes under a published version never change. +- **Private by default.** A scene is visible only to its owning account until + made public (on the provider's website, or by an explicit `visibility`). +- **Moderation.** Providers can *pull* a scene: it disappears from the index, + downloads answer `410`, and republishing over it is rejected + (`403 scene_pulled`). Structural validation at publish may reject + `invalid_zip`, `missing_template_json`, `missing_scenes`, + `413 scene_too_large`, or quota errors (`403 scene_quota_exceeded` / + `storage_quota_exceeded`). Content moderation may reject + `422 content_rejected {categories}` (name/description/preview image + classified before anything is stored) or answer `503 + moderation_unavailable` when the provider's moderation backend is down — + retry later, the publish was not accepted. Abuse controls may answer `403 + store_banned` (account-level publish ban) or `429 + daily_scene_limit_exceeded`. + +## The FrameOS-side API + +Both the backend (FastAPI, login-gated) and the frame's on-device admin server +(Nim, admin-session-gated) expose the same five endpoints, driven by the +shared settings UI: + +```http +GET /api/cloud/status # current state, see shape below +POST /api/cloud/provider # {"provider_url": "…"} — only while disconnected +POST /api/cloud/connect # optional {"provider_url", "scopes"} — starts the device flow +POST /api/cloud/poll # one poll step; the UI calls this on the advertised interval +POST /api/cloud/disconnect # best-effort cloud unlink + local reset +``` + +Phase 1/3 additions (login endpoints are open — the user is not logged in yet; +the rest are login-gated; `/setup/*` only answer while no local user exists): + +```http +GET /api/cloud/login/options # {"available", "provider_url", "local_login_enabled", "setup_mode"} +POST /api/cloud/login/start # {"next"?} → {"authorization_url"} +GET /api/cloud/login/callback # ?code&state → session cookie + redirect +POST /api/cloud/identity/link # logged-in handoff that links the identity instead +POST /api/cloud/identity/unlink +POST /api/cloud/local-fallback # {"enabled": bool} +POST /api/cloud/features # {"scopes": […]} — change enabled features in place +POST /api/cloud/features/cancel # forget a pending feature-change approval +GET|POST /api/cloud/setup/{status,provider,connect,poll,disconnect} +GET /api/cloud/backups # proxied list from the provider +POST /api/cloud/backups/templates # {"template_id"} — push one template +POST /api/cloud/backups/frames # {"frame_id"} — push one frame +POST /api/cloud/backups/restore # {"backup_id", "project_id"} +GET /api/backup/export # local tar.gz of everything (no cloud needed) +POST /api/cloud/store/publish # {"template_id"} or {"name", "scenes", "from_frame_id"?, + # "image_scene_id"?}; "visibility"? — save a scene to the cloud drive +GET /api/cloud/store/drive # "My cloud drive" listing (proxied, image URLs rewritten) +GET /api/cloud/store/drive/image/{sceneId} # preview image proxy (attaches the link token) +``` + +`GET /api/cloud/status` shape (mirrored by `CloudStatus` in +`frontend/src/types.tsx`): + +```json +{ + "enabled": true, + "provider_url": "https://cloud.frameos.net", + "default_provider_url": "https://cloud.frameos.net", + "status": "disconnected | connecting | connected", + "can_edit_provider": true, + "poll_error": null, + "connection": { "user_code": "…", "verification_uri": "…", "verification_uri_complete": "…", "expires_at": "…", "interval_seconds": 5 }, + "link": { "linked_client_id": "…", "scopes": ["…"], "account_id": "…", "account_email": "…", "connected_at": "…", "last_inventory_sync_at": "…" } +} +``` + +`connection` is set only while `connecting`; `link` only while `connected`. +The access token itself is never included. + +## Running your own provider + +Implement the five `{provider}` endpoints above (device start/poll + +inventory/grants/rotate-token/unlink) with these behaviors: + +- device codes: single-use, hashed at rest, short expiry (~10 min), poll rate + limiting with `authorization_pending`; +- user codes: short, human-typable, approval requires an authenticated user + session on your site and must display the requested scopes; +- tokens: opaque bearer secrets, hashed at rest, revocable per link, with a + rotation endpoint that keeps a short grace window for the previous token; +- scopes: enforce on every request; drop unknown requested scopes. + +Then point `FRAMEOS_CLOUD_URL` (or the settings UI) at your origin. Later +phases (login handoff, store, backups, relay) will extend this document as +they are implemented; the scope table above reserves their names. diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png index 6a84f5a5b..8eb4eb5ef 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png index ef5b07536..09a78153c 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png index 200c43785..ccab3f6f9 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png index c0dd81bd1..b9c470a1d 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png index 749857c4e..a5c7929e9 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png index e1511251c..d4af88ead 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png differ diff --git a/frameos/src/frameos/server/routes.nim b/frameos/src/frameos/server/routes.nim index 587d44d95..cff7f633f 100644 --- a/frameos/src/frameos/server/routes.nim +++ b/frameos/src/frameos/server/routes.nim @@ -5,7 +5,7 @@ import mummy/routers import httpcore import frameos/channels import frameos/types -import ./routes/[web_routes, frame_api_routes, admin_api_routes, repository_api_routes, common] +import ./routes/[web_routes, frame_api_routes, admin_api_routes, repository_api_routes, cloud_api_routes, common] proc shouldLogRouteNotFound*(path: string): bool = if path.startsWith("/img/"): @@ -17,6 +17,7 @@ proc buildRouter*(connectionsState: ConnectionsState, adminConnectionsState: Con addFrameApiRoutes(result, connectionsState) addAdminApiRoutes(result) addRepositoryApiRoutes(result) + addCloudApiRoutes(result) result.notFoundHandler = proc(request: Request) {.gcsafe.} = if shouldLogRouteNotFound(request.path): diff --git a/frameos/src/frameos/server/routes/cloud_api_routes.nim b/frameos/src/frameos/server/routes/cloud_api_routes.nim new file mode 100644 index 000000000..6c2b0fb5c --- /dev/null +++ b/frameos/src/frameos/server/routes/cloud_api_routes.nim @@ -0,0 +1,407 @@ +## Linking this frame directly to FrameOS Cloud (no backend in between). +## +## Mirrors the backend's /api/cloud/* endpoints (backend/app/api/cloud.py) so +## the shared React settings section works against either server. Protocol +## documented in docs/cloud-link.md: OAuth 2.0 Device Authorization Grant, +## outbound-only, scoped tokens. Link state lives in ./state/cloud_link.json. + +import json +import locks +import os +import strutils +import times +import mummy +import mummy/routers +import httpcore +import std/httpclient +import frameos/upgrade +import frameos/utils/http_client +import ../api +import ../auth +import ../state + +const + CLOUD_LINK_STATE_PATH = "./state/cloud_link.json" + DEFAULT_CLOUD_PROVIDER_URL = "https://cloud.frameos.net" + CLOUD_REQUEST_TIMEOUT_MS = 15000 + +# Scopes a frame link may request; must stay in sync with docs/cloud-link.md. +const KNOWN_FRAME_SCOPES = [ + "frame:link", + "backup:assets", + "remote:access", + "telemetry:logs", + "telemetry:metrics", +] +const DEFAULT_FRAME_SCOPES = @["frame:link"] + +var cloudLinkLock: Lock +initLock(cloudLinkLock) + +proc isoTimestamp(epoch: int64): string = + format(fromUnix(epoch), "yyyy-MM-dd'T'HH:mm:ss'Z'", utc()) + +proc loadCloudLinkState(): JsonNode = + if fileExists(CLOUD_LINK_STATE_PATH): + try: + let parsed = parseJson(readFile(CLOUD_LINK_STATE_PATH)) + if parsed.kind == JObject: + return parsed + except CatchableError: + discard + %*{"status": "disconnected"} + +proc saveCloudLinkState(state: JsonNode) = + let dir = splitFile(CLOUD_LINK_STATE_PATH).dir + if dir.len > 0 and not dirExists(dir): + createDir(dir) + let tempPath = CLOUD_LINK_STATE_PATH & ".tmp" + writeFile(tempPath, pretty(state, indent = 2) & "\n") + setFilePermissions(tempPath, {fpUserRead, fpUserWrite}) + if fileExists(CLOUD_LINK_STATE_PATH): + removeFile(CLOUD_LINK_STATE_PATH) + moveFile(tempPath, CLOUD_LINK_STATE_PATH) + +proc normalizeProviderUrl(value: string): string = + ## Empty string means "invalid"; callers fall back or reject. + var url = value.strip() + if url.len == 0: + return "" + if not (url.startsWith("http://") or url.startsWith("https://")): + return "" + while url.endsWith("/"): + url = url[0 ..< url.len - 1] + url + +proc providerUrlFromState(state: JsonNode): string = + let stored = normalizeProviderUrl(state{"provider_url"}.getStr("")) + if stored.len > 0: stored else: DEFAULT_CLOUD_PROVIDER_URL + +proc resetLinkState(state: JsonNode, pollError: string = "") = + let providerUrl = providerUrlFromState(state) + for key in ["device_code", "user_code", "verification_uri", "verification_uri_complete", + "expires_epoch", "access_token", "token_reference", "linked_client_id", + "account_id", "account_email", "scope", "poll_error", "local_origin", + "connected_at", "last_inventory_sync_at"]: + if state.hasKey(key): + state.delete(key) + state["provider_url"] = %providerUrl + state["status"] = %"disconnected" + if pollError.len > 0: + state["poll_error"] = %pollError + +proc expireIfNeeded(state: JsonNode): bool = + if state{"status"}.getStr("") == "connecting" and + state{"expires_epoch"}.getInt(0) > 0 and + int64(state{"expires_epoch"}.getInt(0)) <= int64(epochTime()): + resetLinkState(state, pollError = "expired") + return true + false + +proc cloudStatusPayload(state: JsonNode): JsonNode = + let status = state{"status"}.getStr("disconnected") + result = %*{ + "enabled": true, + "provider_url": providerUrlFromState(state), + "default_provider_url": DEFAULT_CLOUD_PROVIDER_URL, + "status": status, + "can_edit_provider": status == "disconnected", + "poll_error": state{"poll_error"}, + "connection": newJNull(), + "link": newJNull(), + } + if status == "connecting": + result["connection"] = %*{ + "user_code": state{"user_code"}, + "verification_uri": state{"verification_uri"}, + "verification_uri_complete": state{"verification_uri_complete"}, + "expires_at": ( + if state{"expires_epoch"}.getInt(0) > 0: + %isoTimestamp(int64(state{"expires_epoch"}.getInt(0))) + else: + newJNull() + ), + "interval_seconds": state{"interval_seconds"}.getInt(5), + } + if status == "connected": + var scopes = newJArray() + for scope in state{"scope"}.getStr("").splitWhitespace(): + scopes.add(%scope) + result["link"] = %*{ + "linked_client_id": state{"linked_client_id"}, + "scopes": scopes, + "account_id": state{"account_id"}, + "account_email": state{"account_email"}, + "connected_at": state{"connected_at"}, + "last_inventory_sync_at": state{"last_inventory_sync_at"}, + } + +proc cloudRequest(providerUrl, path: string, httpMethod = HttpPost, + accessToken = "", body: JsonNode = nil): (int, JsonNode) = + var headers = newHttpHeaders({"Accept": "application/json"}) + if body != nil: + headers["Content-Type"] = "application/json" + if accessToken.len > 0: + headers["Authorization"] = "Bearer " & accessToken + let url = providerUrl & "/" & path.strip(leading = true, chars = {'/'}) + let response = boundedRequest( + url, + httpMethod = httpMethod, + body = (if body != nil: $body else: ""), + headers = headers, + timeoutMs = CLOUD_REQUEST_TIMEOUT_MS, + ) + var payload: JsonNode = nil + try: + payload = parseJson(response.body) + except CatchableError: + discard + if payload == nil or payload.kind != JObject: + payload = %*{} + (response.code, payload) + +proc requestedScopes(payload: JsonNode): seq[string] = + if payload{"scopes"} != nil and payload{"scopes"}.kind == JArray: + for scope in payload{"scopes"}: + if scope.kind == JString and scope.getStr() in KNOWN_FRAME_SCOPES: + result.add(scope.getStr()) + if result.len == 0: + result = DEFAULT_FRAME_SCOPES + +proc requestHeader(request: Request, name: string): string = + for (headerName, value) in request.headers: + if cmpIgnoreCase(headerName, name) == 0: + return value + "" + +proc localOrigin*(request: Request): string = + let forwardedProto = requestHeader(request, "x-forwarded-proto") + .split(",", 1)[0].strip().toLowerAscii() + let scheme = if forwardedProto == "https": "https" else: "http" + var host = requestHeader(request, "host") + if host.len == 0: + host = "localhost" + scheme & "://" & host + +proc syncAfterConnect(state: JsonNode, providerUrl, accessToken: string) = + ## Best effort: report inventory and learn which account owns us. + try: + let (inventoryCode, _) = cloudRequest(providerUrl, "/api/backends/inventory", + accessToken = accessToken, body = %*{ + "reported_frameos_version": installedFrameOSVersion(), + "capabilities": {"localFallback": true, "frame": true}, + "health": {"status": "ok"}, + }) + if inventoryCode == 200: + state["last_inventory_sync_at"] = %isoTimestamp(int64(epochTime())) + except CatchableError: + discard + try: + let (grantsCode, grants) = cloudRequest(providerUrl, "/api/backends/grants", + httpMethod = HttpGet, accessToken = accessToken) + if grantsCode == 200 and grants{"grants"} != nil and grants{"grants"}.kind == JArray: + for grant in grants{"grants"}: + if grant.kind == JObject and grant{"role"}.getStr("") == "owner": + state["account_id"] = grant{"account_id"} + state["account_email"] = grant{"account_email"} + break + except CatchableError: + discard + +proc addCloudApiRoutes*(router: var Router) = + router.get("/api/cloud/status", proc(request: Request) {.gcsafe.} = + if not hasAdminAccess(request): + jsonResponse(request, Http401, %*{"detail": "Unauthorized"}) + return + {.gcsafe.}: + withLock cloudLinkLock: + let state = loadCloudLinkState() + if expireIfNeeded(state): + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + ) + + router.post("/api/cloud/provider", proc(request: Request) {.gcsafe.} = + if not hasAdminAccess(request): + jsonResponse(request, Http401, %*{"detail": "Unauthorized"}) + return + {.gcsafe.}: + let payload = try: + parseJson(if request.body.strip().len == 0: "{}" else: request.body) + except JsonParsingError: + jsonResponse(request, Http400, %*{"detail": "Invalid JSON"}) + return + let providerUrl = normalizeProviderUrl(payload{"provider_url"}.getStr("")) + if providerUrl.len == 0: + jsonResponse(request, Http400, %*{"detail": "The FrameOS Cloud server must be an http(s) URL"}) + return + withLock cloudLinkLock: + let state = loadCloudLinkState() + discard expireIfNeeded(state) + if state{"status"}.getStr("disconnected") != "disconnected": + jsonResponse(request, Http409, + %*{"detail": "Disconnect from FrameOS Cloud before changing the server URL"}) + return + state["provider_url"] = %providerUrl + if state.hasKey("poll_error"): + state.delete("poll_error") + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + ) + + router.post("/api/cloud/connect", proc(request: Request) {.gcsafe.} = + if not hasAdminAccess(request): + jsonResponse(request, Http401, %*{"detail": "Unauthorized"}) + return + {.gcsafe.}: + let payload = try: + parseJson(if request.body.strip().len == 0: "{}" else: request.body) + except JsonParsingError: + jsonResponse(request, Http400, %*{"detail": "Invalid JSON"}) + return + + var providerUrl = "" + var displayName = "FrameOS frame" + withLock cloudLinkLock: + let state = loadCloudLinkState() + discard expireIfNeeded(state) + if state{"status"}.getStr("") == "connected": + jsonResponse(request, Http409, %*{"detail": "Already connected to FrameOS Cloud"}) + return + let fromBody = normalizeProviderUrl(payload{"provider_url"}.getStr("")) + providerUrl = if fromBody.len > 0: fromBody else: providerUrlFromState(state) + if globalFrameConfig != nil and globalFrameConfig.name.len > 0: + displayName = "FrameOS frame (" & globalFrameConfig.name & ")" + + let scopes = requestedScopes(payload) + var scopesJson = newJArray() + for scope in scopes: + scopesJson.add(%scope) + var startResponse: JsonNode + var startCode = 0 + let origin = localOrigin(request) + try: + (startCode, startResponse) = cloudRequest(providerUrl, "/api/device/start", body = %*{ + "public_display_name": displayName, + "local_origin": origin, + "reported_frameos_version": installedFrameOSVersion(), + "capabilities": {"localFallback": true, "frame": true}, + "client_kind": "frame", + "scopes": scopesJson, + }) + except CatchableError as error: + jsonResponse(request, Http502, %*{"detail": "Could not reach " & providerUrl & ": " & error.msg}) + return + if startCode != 200 or startResponse{"device_code"}.getStr("") == "": + let detail = startResponse{"error"}.getStr("unexpected status " & $startCode) + jsonResponse(request, Http502, %*{"detail": "FrameOS Cloud rejected the request: " & detail}) + return + + withLock cloudLinkLock: + let state = loadCloudLinkState() + resetLinkState(state) + state["provider_url"] = %providerUrl + state["status"] = %"connecting" + state["local_origin"] = %origin + state["device_code"] = startResponse{"device_code"} + state["user_code"] = startResponse{"user_code"} + state["verification_uri"] = startResponse{"verification_uri"} + state["verification_uri_complete"] = startResponse{"verification_uri_complete"} + state["interval_seconds"] = %startResponse{"interval"}.getInt(5) + state["scope"] = %scopes.join(" ") + let expiresIn = startResponse{"expires_in"}.getInt(0) + if expiresIn > 0: + state["expires_epoch"] = %int(epochTime() + float(expiresIn)) + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + ) + + router.post("/api/cloud/poll", proc(request: Request) {.gcsafe.} = + if not hasAdminAccess(request): + jsonResponse(request, Http401, %*{"detail": "Unauthorized"}) + return + {.gcsafe.}: + var providerUrl = "" + var deviceCode = "" + withLock cloudLinkLock: + let state = loadCloudLinkState() + if expireIfNeeded(state): + saveCloudLinkState(state) + if state{"status"}.getStr("") != "connecting" or state{"device_code"}.getStr("") == "": + jsonResponse(request, Http200, cloudStatusPayload(state)) + return + providerUrl = providerUrlFromState(state) + deviceCode = state{"device_code"}.getStr("") + + var pollCode = 0 + var pollResponse: JsonNode = %*{} + var networkError = false + try: + (pollCode, pollResponse) = cloudRequest(providerUrl, "/api/device/poll", + body = %*{"device_code": deviceCode}) + except CatchableError: + networkError = true + + withLock cloudLinkLock: + let state = loadCloudLinkState() + if state{"status"}.getStr("") != "connecting" or state{"device_code"}.getStr("") != deviceCode: + jsonResponse(request, Http200, cloudStatusPayload(state)) + return + if networkError: + state["poll_error"] = %"network_error" + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + return + let error = pollResponse{"error"}.getStr("") + if error == "authorization_pending": + if state.hasKey("poll_error"): + state.delete("poll_error") + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + return + if pollCode == 200 and pollResponse{"access_token"}.getStr("") != "": + let accessToken = pollResponse{"access_token"}.getStr("") + state["status"] = %"connected" + state["access_token"] = %accessToken + state["token_reference"] = pollResponse{"token_reference"} + state["linked_client_id"] = pollResponse{"linked_client_id"} + if pollResponse{"scope"}.getStr("") != "": + state["scope"] = pollResponse{"scope"} + for key in ["device_code", "user_code", "verification_uri", + "verification_uri_complete", "expires_epoch", "poll_error"]: + if state.hasKey(key): + state.delete(key) + state["connected_at"] = %isoTimestamp(int64(epochTime())) + syncAfterConnect(state, providerUrl, accessToken) + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + return + resetLinkState(state, pollError = (if error.len > 0: error else: "unexpected status " & $pollCode)) + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + ) + + router.post("/api/cloud/disconnect", proc(request: Request) {.gcsafe.} = + if not hasAdminAccess(request): + jsonResponse(request, Http401, %*{"detail": "Unauthorized"}) + return + {.gcsafe.}: + var providerUrl = "" + var accessToken = "" + withLock cloudLinkLock: + let state = loadCloudLinkState() + providerUrl = providerUrlFromState(state) + if state{"status"}.getStr("") == "connected": + accessToken = state{"access_token"}.getStr("") + if accessToken.len > 0: + try: + discard cloudRequest(providerUrl, "/api/backends/unlink", accessToken = accessToken, body = %*{}) + except CatchableError: + # Local disconnect must work while the cloud is down. + discard + withLock cloudLinkLock: + let state = loadCloudLinkState() + resetLinkState(state) + saveCloudLinkState(state) + jsonResponse(request, Http200, cloudStatusPayload(state)) + ) diff --git a/frameos/src/frameos/server/tests/test_auth.nim b/frameos/src/frameos/server/tests/test_auth.nim index 04920f92f..f92feac77 100644 --- a/frameos/src/frameos/server/tests/test_auth.nim +++ b/frameos/src/frameos/server/tests/test_auth.nim @@ -6,6 +6,7 @@ import mummy import ../../types import ../state import ../auth +import ../routes/cloud_api_routes let missingConfigPath = getTempDir() / ("frameos-auth-tests-missing-frame-" & $getCurrentProcessId() & ".json") @@ -54,6 +55,18 @@ suite "Server auth helpers": check not adminPanelEnabled() check not adminAuthEnabled() + test "cloud callback origin follows the external proxy scheme and host": + check localOrigin(makeRequest(headers = @[("host", "frame.local:8787")])) == + "http://frame.local:8787" + check localOrigin(makeRequest(headers = @[ + ("host", "frame.example:8443"), + ("x-forwarded-proto", "https"), + ])) == "https://frame.example:8443" + check localOrigin(makeRequest(headers = @[ + ("host", "frame.example"), + ("x-forwarded-proto", "https, http"), + ])) == "https://frame.example" + test "legacy provider field does not affect admin auth": globalFrameConfig = FrameConfig( frameAdminAuth: %*{ diff --git a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx index afbbe9986..2092618ac 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx +++ b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx @@ -55,6 +55,7 @@ import { TextArea } from '../../../../components/TextArea' import { ColorInput } from '../../../../components/ColorInput' import { settingsLogic } from '../../../settings/settingsLogic' import { isInFrameAdminMode } from '../../../../utils/frameAdmin' +import { CloudSettingsSection } from '../../../settings/CloudSettings' import { normalizeSshKeys } from '../../../../utils/sshKeys' import { Label } from '../../../../components/Label' import { logsLogic } from '../Logs/logsLogic' @@ -290,13 +291,8 @@ function FrameAdminServiceSecretsSection(): JSX.Element { } function FrameAdminUpgradeSection(): JSX.Element { - const { - upgradeStatus, - upgradeStatusLoading, - isUpgradePolling, - upgradeError, - upgradeStatusIsActive, - } = useValues(frameAdminUpgradeLogic) + const { upgradeStatus, upgradeStatusLoading, isUpgradePolling, upgradeError, upgradeStatusIsActive } = + useValues(frameAdminUpgradeLogic) const { checkUpgradeStatus, dryRunUpgrade, confirmStartUpgrade, loadUpgradeStatus } = useActions(frameAdminUpgradeLogic) @@ -427,11 +423,9 @@ const ESP32_PIN_FIELDS: { key: Esp32PinKey; label: string }[] = [ { key: 'pwr', label: 'PWR' }, ] -const ESP32_WAVESHARE_13IN3E6_HARDWARE_PRESET: FrameEmbeddedHardwarePreset = - 'waveshare_esp32_s3_epaper_13_3e6' +const ESP32_WAVESHARE_13IN3E6_HARDWARE_PRESET: FrameEmbeddedHardwarePreset = 'waveshare_esp32_s3_epaper_13_3e6' const ESP32_WAVESHARE_13IN3E6_DEVICE = 'waveshare.EPD_13in3e' -const ESP32_WAVESHARE_PHOTOPAINTER_HARDWARE_PRESET: FrameEmbeddedHardwarePreset = - 'waveshare_esp32_s3_photopainter' +const ESP32_WAVESHARE_PHOTOPAINTER_HARDWARE_PRESET: FrameEmbeddedHardwarePreset = 'waveshare_esp32_s3_photopainter' const ESP32_WAVESHARE_PHOTOPAINTER_DEVICE = 'waveshare.EPD_7in3e' const INKY_GPIO_BUTTONS: GPIOButton[] = [ { pin: 5, label: 'A' }, @@ -613,9 +607,7 @@ function esp32RecommendedPinLayout( if (presetConfig) { return { ...presetConfig.pins } } - return device === ESP32_WAVESHARE_13IN3E6_DEVICE - ? { ...ESP32_XIAO_13IN3E_PIN_LAYOUT } - : { ...ESP32_XIAO_PIN_LAYOUT } + return device === ESP32_WAVESHARE_13IN3E6_DEVICE ? { ...ESP32_XIAO_13IN3E_PIN_LAYOUT } : { ...ESP32_XIAO_PIN_LAYOUT } } function normalizeEsp32PinNumber(value: unknown, fallback: number): number { @@ -646,8 +638,7 @@ function normalizeEsp32SdCardPinLayout( function normalizeEsp32SdCardAssets(value: Esp32SdCardAssets | undefined): NormalizedEsp32SdCardAssets { const preset = - value?.preset === 'waveshare_esp32_s3_photopainter' || - value?.preset === ESP32_WAVESHARE_13IN3E6_HARDWARE_PRESET + value?.preset === 'waveshare_esp32_s3_photopainter' || value?.preset === ESP32_WAVESHARE_13IN3E6_HARDWARE_PRESET ? value.preset : 'custom' const presetPins = @@ -849,10 +840,7 @@ export function FrameSettings({ sdCardAssets: presetConfig.sdCardAssets, }, } - if ( - !frameForm.max_http_response_bytes || - frameForm.max_http_response_bytes === DEFAULT_MAX_HTTP_RESPONSE_BYTES - ) { + if (!frameForm.max_http_response_bytes || frameForm.max_http_response_bytes === DEFAULT_MAX_HTTP_RESPONSE_BYTES) { nextValues.max_http_response_bytes = EMBEDDED_DEFAULT_MAX_HTTP_RESPONSE_BYTES } setFrameFormValues(nextValues) @@ -984,11 +972,7 @@ export function FrameSettings({ const imageUrl = frameImageUrl(linkFrame) const embeddedAdminAuthMissing = isEmbeddedMode && - !( - linkFrame.frame_admin_auth?.enabled && - linkFrame.frame_admin_auth.user && - linkFrame.frame_admin_auth.pass - ) + !(linkFrame.frame_admin_auth?.enabled && linkFrame.frame_admin_auth.user && linkFrame.frame_admin_auth.pass) const frameActionsMenu = hideDropdown ? null : ( + {/* Contains its own
, so it must stay outside the frameForm below. */} + {inFrameAdminMode ? : null}
- Set an admin username and password before deploying ESP32 firmware. Without it, the on-frame setup - URL is locked outside hotspot mode. + Set an admin username and password before deploying ESP32 firmware. Without it, the on-frame setup URL + is locked outside hotspot mode.
) : null} diff --git a/frontend/src/scenes/settings/CloudSettings.tsx b/frontend/src/scenes/settings/CloudSettings.tsx new file mode 100644 index 000000000..0e0c544f4 --- /dev/null +++ b/frontend/src/scenes/settings/CloudSettings.tsx @@ -0,0 +1,235 @@ +import { useActions, useValues } from 'kea' +import { Form } from 'kea-forms' +import { PencilSquareIcon } from '@heroicons/react/24/solid' + +import { Box } from '../../components/Box' +import { Button } from '../../components/Button' +import { Field } from '../../components/Field' +import { H6 } from '../../components/H6' +import { Label } from '../../components/Label' +import { Spinner } from '../../components/Spinner' +import { Tag } from '../../components/Tag' +import { TextInput } from '../../components/TextInput' +import { isInFrameAdminMode } from '../../utils/frameAdmin' +import { CLOUD_FEATURES, cloudLogic } from './cloudLogic' + +function pollErrorMessage(pollError: string): string { + switch (pollError) { + case 'expired': + case 'expired_token': + return 'The link code expired before it was approved. Try connecting again.' + case 'access_denied': + return 'The link request was denied in FrameOS Cloud.' + case 'network_error': + return 'Could not reach the FrameOS Cloud server. Check the URL and your network.' + default: + return `Connection failed: ${pollError}` + } +} + +function expiresInLabel(expiresAt: string | null): string | null { + if (!expiresAt) { + return null + } + const secondsLeft = Math.round((new Date(expiresAt).getTime() - Date.now()) / 1000) + if (secondsLeft <= 0) { + return 'expired' + } + if (secondsLeft < 60) { + return `expires in ${secondsLeft}s` + } + return `expires in ${Math.ceil(secondsLeft / 60)} min` +} + +/** "FrameOS Cloud" settings section. Shared between the backend's global + * settings page and the on-device frame admin — both servers implement the + * same /api/cloud/* endpoints (see docs/cloud-link.md). */ +export function CloudSettingsSection({ headingId = 'settings-cloud' }: { headingId?: string }): JSX.Element | null { + const { + cloudStatus, + cloudStatusLoading, + cloudError, + providerEditorOpen, + isProviderUrlSubmitting, + isCloudConnecting, + isCloudDisconnecting, + } = useValues(cloudLogic) + const { connectCloud, disconnectCloud, setProviderEditorOpen } = useActions(cloudLogic) + const frameAdminMode = isInFrameAdminMode() + + if (cloudStatus && !cloudStatus.enabled) { + // FRAMEOS_CLOUD_URL=disabled hides the whole section + return null + } + + const status = cloudStatus?.status ?? 'disconnected' + const providerUrl = cloudStatus?.provider_url ?? 'https://cloud.frameos.net' + const providerHost = providerUrl.replace(/^https?:\/\//, '') + const connection = cloudStatus?.connection + const link = cloudStatus?.link + const expiresLabel = connection ? expiresInLabel(connection.expires_at) : null + + return ( +
+
+
FrameOS Cloud
+ {status === 'connected' ? Connected : null} +
+ + {cloudStatusLoading && !cloudStatus ? ( + + ) : status === 'connected' && link ? ( + <> +
+
+ +
+
+ {providerHost} + {link.account_email ? as {link.account_email} : null} + +
+
+ {!frameAdminMode ? ( +
+
+ +
+
+ {CLOUD_FEATURES.map(({ scope, label, description }) => ( + + ))} +
+
+ ) : null} + + ) : status === 'connecting' && connection ? ( + <> +
+ To link this FrameOS with your cloud account, open the approval page and enter this code: +
+
+ + {connection.user_code} + + {connection.verification_uri_complete || connection.verification_uri ? ( + + ) : null} +
+
+ + Waiting for approval{expiresLabel ? ` (${expiresLabel})` : ''}… + +
+ + ) : ( + <> +
+
+ +
+ {providerEditorOpen ? ( + + + + +
+ + +
+ + ) : ( +
+ {providerUrl} + +
+ )} +
+
+
+
+ +
+
+ {cloudStatus?.poll_error ? ( +
{pollErrorMessage(cloudStatus.poll_error)}
+ ) : null} +
+ Connect this backend to a cloud account to optionally enable a few extra features: cloud login, offsite + backups of your frames and templates, etc. Soon also remote access and more. +
+ + )} + {cloudError ?
{cloudError}
: null} + +
+ ) +} diff --git a/frontend/src/scenes/settings/Settings.tsx b/frontend/src/scenes/settings/Settings.tsx index fd0c3a003..bbf78865e 100644 --- a/frontend/src/scenes/settings/Settings.tsx +++ b/frontend/src/scenes/settings/Settings.tsx @@ -26,6 +26,7 @@ import { Label } from '../../components/Label' import { FrameosShell } from '../workspace/FrameosShell' import { isMobileWorkspaceViewport, workspaceLogic } from '../workspace/workspaceLogic' import { accountLogic } from './accountLogic' +import { CloudSettingsSection } from './CloudSettings' import versions from '../../../../versions.json' import { timezoneOptions } from '../../decorators/timezones' import { systemInfoLogic } from './systemInfoLogic' @@ -41,7 +42,10 @@ type SettingsSectionId = string const settingsNavSections: readonly SettingsNavSection[] = [ { label: '', - items: [['Account', '#settings-account']], + items: [ + ['Account', '#settings-account'], + ['FrameOS Cloud', '#settings-cloud'], + ], }, { label: 'Settings', @@ -156,7 +160,7 @@ function AccountSettingsSection({ onLogout }: { onLogout: () => void }): JSX.Ele return (
-
Account
+
Local account
@@ -462,6 +466,7 @@ export function Settings() {
{isHassioIngress ? : } + {savedSettingsLoading ? ( ) : ( @@ -954,9 +959,9 @@ export function Settings() { label="Share frames with Home Assistant" tooltip={ <> - Each frame becomes a device with a live image and status/scene sensors (via MQTT - discovery), and frame events are forwarded to the Home Assistant event bus as{' '} - frameos_event for use in automations. Archived frames are not shared. + Each frame becomes a device with a live image and status/scene sensors (via MQTT discovery), + and frame events are forwarded to the Home Assistant event bus as frameos_event{' '} + for use in automations. Archived frames are not shared. } > diff --git a/frontend/src/scenes/settings/cloudLogic.tsx b/frontend/src/scenes/settings/cloudLogic.tsx new file mode 100644 index 000000000..614377182 --- /dev/null +++ b/frontend/src/scenes/settings/cloudLogic.tsx @@ -0,0 +1,209 @@ +import { actions, afterMount, kea, listeners, path, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { loaders } from 'kea-loaders' + +import { CloudStatus } from '../../types' +import { apiFetch } from '../../utils/apiFetch' +import { isInFrameAdminMode } from '../../utils/frameAdmin' + +import type { cloudLogicType } from './cloudLogicType' + +export interface CloudProviderForm { + provider_url: string +} + +/** The features a link can enable, in the wording the consent screen uses. + * Kept in sync with the scope table in CLOUD-TODO.md. + * + * Everything that is safe comes with the cloud account itself and is + * requested with the link; 'locked' renders an always-on checkbox. + * Security-sensitive features (cloud login, remote access, ...) will get a + * cloud-approved opt-in toggle when they ship. */ +export const CLOUD_FEATURES: { + scope: string + label: string + description: string + control: 'locked' +}[] = [ + { + scope: 'store:publish', + label: 'Save and share scenes via the cloud', + description: 'Save scenes to your cloud account and share them on the FrameOS store', + control: 'locked', + }, + { + scope: 'backup:scenes', + label: 'Scene backups', + description: 'Back up your scenes into the cloud', + control: 'locked', + }, + { + scope: 'backup:frames', + label: 'Frame backups', + description: 'Back up frame settings + scenes automatically after each deploy', + control: 'locked', + }, +] + +/** Scopes that come with every connected cloud account; requested at link time. */ +export const INCLUDED_FEATURE_SCOPES = CLOUD_FEATURES.map(({ scope }) => scope) + +const BASE_SCOPES = ['backend:link', 'backend:read'] + +async function cloudErrorMessage(response: Response, fallback: string): Promise { + try { + const payload = await response.json() + if (typeof payload?.detail === 'string') { + return payload.detail + } + } catch { + // Use fallback below. + } + return fallback +} + +/** Drives the "FrameOS Cloud" settings section, both on the backend and in the + * on-device frame admin — the /api/cloud/* endpoints exist on both servers. */ +export const cloudLogic = kea([ + path(['src', 'scenes', 'settings', 'cloudLogic']), + actions({ + connectCloud: true, + pollCloud: true, + disconnectCloud: true, + setProviderEditorOpen: (open: boolean) => ({ open }), + setCloudError: (error: string | null) => ({ error }), + }), + loaders(() => ({ + cloudStatus: [ + null as CloudStatus | null, + { + loadCloudStatus: async () => { + const response = await apiFetch('/api/cloud/status') + if (!response.ok) { + throw new Error(await cloudErrorMessage(response, 'Failed to load FrameOS Cloud status')) + } + return (await response.json()) as CloudStatus + }, + }, + ], + })), + reducers({ + providerEditorOpen: [ + false, + { + setProviderEditorOpen: (_, { open }) => open, + submitProviderUrlSuccess: () => false, + }, + ], + cloudError: [ + null as string | null, + { + setCloudError: (_, { error }) => error, + connectCloud: () => null, + disconnectCloud: () => null, + loadCloudStatus: () => null, + }, + ], + isCloudConnecting: [ + false, + { + connectCloud: () => true, + loadCloudStatusSuccess: () => false, + setCloudError: () => false, + }, + ], + isCloudDisconnecting: [ + false, + { + disconnectCloud: () => true, + loadCloudStatusSuccess: () => false, + setCloudError: () => false, + }, + ], + }), + forms(({ actions }) => ({ + providerUrl: { + defaults: { provider_url: '' } as CloudProviderForm, + errors: (form: Partial) => ({ + provider_url: !form.provider_url?.trim() ? 'Enter a server URL' : null, + }), + submit: async (form) => { + const response = await apiFetch('/api/cloud/provider', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider_url: form.provider_url.trim() }), + }) + if (!response.ok) { + actions.setProviderUrlManualErrors({ + provider_url: await cloudErrorMessage(response, 'Failed to update the server URL'), + }) + return + } + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + }, + })), + selectors({ + cloudProviderUrl: [ + (s) => [s.cloudStatus], + (cloudStatus): string => cloudStatus?.provider_url ?? 'https://cloud.frameos.net', + ], + grantedScopes: [(s) => [s.cloudStatus], (cloudStatus): string[] => cloudStatus?.link?.scopes ?? []], + }), + listeners(({ actions, values }) => ({ + connectCloud: async () => { + // Connecting asks for the link plus every included ("safe") feature in + // one approval. + const scopes = isInFrameAdminMode() ? ['frame:link'] : [...BASE_SCOPES, ...INCLUDED_FEATURE_SCOPES] + const response = await apiFetch('/api/cloud/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scopes }), + }) + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Failed to connect to FrameOS Cloud')) + return + } + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + pollCloud: async (_, breakpoint) => { + if (values.cloudStatus?.status !== 'connecting') { + return + } + const response = await apiFetch('/api/cloud/poll', { method: 'POST' }) + breakpoint() + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Failed to poll FrameOS Cloud')) + return + } + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + // Keep polling while the device flow is pending; loadCloudStatusSuccess + // fires for every status transition, so this self-schedules until the + // status leaves "connecting" (approval, denial, expiry or disconnect). + loadCloudStatusSuccess: async ({ cloudStatus }, breakpoint) => { + if (cloudStatus?.status === 'connecting') { + await breakpoint((cloudStatus.connection?.interval_seconds ?? 5) * 1000) + actions.pollCloud() + } + }, + disconnectCloud: async () => { + const response = await apiFetch('/api/cloud/disconnect', { method: 'POST' }) + if (!response.ok) { + actions.setCloudError(await cloudErrorMessage(response, 'Failed to disconnect from FrameOS Cloud')) + return + } + actions.loadCloudStatusSuccess((await response.json()) as CloudStatus) + }, + setProviderEditorOpen: ({ open }) => { + if (open) { + actions.setProviderUrlValues({ provider_url: values.cloudProviderUrl }) + } else { + actions.resetProviderUrl() + } + }, + })), + afterMount(({ actions }) => { + actions.loadCloudStatus() + }), +]) diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index 09650bfbf..b4a6a10f2 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -807,6 +807,31 @@ export interface FrameOSSettings { } } +/** Mirrors GET /api/cloud/status (backend/app/api/cloud.py and the frame's cloud_api_routes.nim) */ +export interface CloudStatus { + enabled: boolean + provider_url: string | null + default_provider_url: string | null + status: 'disconnected' | 'connecting' | 'connected' + can_edit_provider: boolean + poll_error: string | null + connection: { + user_code: string | null + verification_uri: string | null + verification_uri_complete: string | null + expires_at: string | null + interval_seconds: number + } | null + link: { + linked_client_id: string | null + scopes: string[] + account_id: string | null + account_email: string | null + connected_at: string | null + last_inventory_sync_at: string | null + } | null +} + export interface SSHKeyEntry { id: string name?: string diff --git a/frontend/src/utils/projectApi.ts b/frontend/src/utils/projectApi.ts index cabfa51e8..e46a50c72 100644 --- a/frontend/src/utils/projectApi.ts +++ b/frontend/src/utils/projectApi.ts @@ -90,6 +90,9 @@ export function isProjectScopedApiPath(path: string): boolean { path === '/api/user' || path.startsWith('/api/user/') || path.startsWith('/api/system/') || + // the cloud link belongs to the installation, not a project + path === '/api/cloud' || + path.startsWith('/api/cloud/') || path === '/api/generate_ssh_keys' || path === '/api/log' || path === '/api/repositories/system' ||