From 10cc8351b4e7ddf0ac491accb6af1e037a4f9eb5 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 18 Jul 2026 23:23:23 +0200 Subject: [PATCH 1/2] Cloud link: device-flow linking of backends and frames to FrameOS Cloud Phase 0 of CLOUD-TODO.md. Links this install to cloud.frameos.net (or any compatible self-hosted provider) via the OAuth 2.0 Device Authorization Grant, with a scoped permission model. The token is encrypted at rest and every connection is outbound-only; the protocol is documented in docs/cloud-link.md. - Backend: /api/cloud/{status,provider,connect,poll,disconnect}, link state in cloud_backend_link (tables shipped earlier), account owner learned from grants at connect time - Frame: the same endpoints implemented on-device (cloud_api_routes.nim), link state in ./state/cloud_link.json - Frontend: "FrameOS Cloud" settings section shared between the backend settings page and the on-device frame admin - Safe feature scopes (backups, store publishing) are requested with the link; security-sensitive scopes (cloud login, ...) come later with an explicit opt-in toggle Co-Authored-By: Claude Fable 5 --- CLOUD-TODO.md | 269 ++++++++++ backend/app/api/__init__.py | 1 + backend/app/api/cloud.py | 314 ++++++++++++ backend/app/api/tests/test_cloud.py | 208 ++++++++ backend/app/config.py | 4 + backend/app/models/__init__.py | 1 + backend/app/models/cloud.py | 111 ++++ backend/app/schemas/cloud.py | 14 + backend/app/utils/cloud_link.py | 147 ++++++ .../e3a1b5c7d9f2_cloud_link_account_fields.py | 27 + backend/requirements.docker.in | 2 + backend/requirements.in | 2 + docs/cloud-link.md | 482 ++++++++++++++++++ frameos/src/frameos/server/routes.nim | 3 +- .../server/routes/cloud_api_routes.nim | 407 +++++++++++++++ .../src/frameos/server/tests/test_auth.nim | 13 + .../panels/FrameSettings/FrameSettings.tsx | 45 +- .../src/scenes/settings/CloudSettings.tsx | 235 +++++++++ frontend/src/scenes/settings/Settings.tsx | 15 +- frontend/src/scenes/settings/cloudLogic.tsx | 209 ++++++++ frontend/src/types.tsx | 25 + frontend/src/utils/projectApi.ts | 3 + 22 files changed, 2500 insertions(+), 37 deletions(-) create mode 100644 CLOUD-TODO.md create mode 100644 backend/app/api/cloud.py create mode 100644 backend/app/api/tests/test_cloud.py create mode 100644 backend/app/models/cloud.py create mode 100644 backend/app/schemas/cloud.py create mode 100644 backend/app/utils/cloud_link.py create mode 100644 backend/migrations/versions/e3a1b5c7d9f2_cloud_link_account_fields.py create mode 100644 docs/cloud-link.md create mode 100644 frameos/src/frameos/server/routes/cloud_api_routes.nim create mode 100644 frontend/src/scenes/settings/CloudSettings.tsx create mode 100644 frontend/src/scenes/settings/cloudLogic.tsx 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/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' || From 1dee6e0019af8e8c37a92835c4886aef2d1f5294 Mon Sep 17 00:00:00 2001 From: FrameOS Bot Date: Sat, 18 Jul 2026 21:59:34 +0000 Subject: [PATCH 2/2] Update frontend visual snapshots --- .../global-settings--default--dark--full.png | Bin 891291 -> 953079 bytes .../global-settings--default--dark--mid.png | Bin 564039 -> 610928 bytes ...global-settings--default--dark--mobile.png | Bin 448151 -> 488513 bytes .../global-settings--default--light--full.png | Bin 625538 -> 662201 bytes .../global-settings--default--light--mid.png | Bin 487646 -> 523985 bytes ...lobal-settings--default--light--mobile.png | Bin 396998 -> 441926 bytes 6 files changed, 0 insertions(+), 0 deletions(-) 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 6a84f5a5bb2db0b33c88ab5446629d666f49367c..8eb4eb5efa6dbe390fca9562e04722fdcb70185b 100644 GIT binary patch literal 953079 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 IlsBMPz7NNU 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