orca_cloud.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. """
  2. Orca Cloud API Service
  3. Handles pairing and profile sync with the Orca Cloud external-app surface.
  4. Auth shape: OAuth 2.0 Device Authorization Grant (RFC 8628). Bambuddy is a
  5. public client (``client_id`` only, no secret) — there is no redirect URL, so
  6. the flow works from a LAN IP, ``localhost``, or behind a reverse proxy. The
  7. user approves a short ``user_code`` in their Orca Cloud settings; Bambuddy
  8. polls the token endpoint until a token pair is issued.
  9. POST /oauth/device/code -> {device_code, user_code, verification_uri,
  10. verification_uri_complete, expires_in, interval}
  11. POST /oauth/token -> poll with grant_type=device_code, then later
  12. refresh with grant_type=refresh_token
  13. Token shape: opaque ``oc_ext_`` access token (24h) + single-use rotating
  14. ``oc_ext_rt_`` refresh token (90-day, renewed on each rotation). Reuse of a
  15. consumed refresh token beyond a ~60s server-side grace window revokes the
  16. whole pairing, so the route layer MUST persist the new pair atomically with
  17. consuming the old one. Within the grace window a lost refresh race is a no-op
  18. (each racer gets its own fresh pair), so single-flighting is hygiene, not a
  19. correctness requirement.
  20. API surface: ``oc_ext_`` tokens authorize ONLY the ``/api/v1/external/*``
  21. endpoints (introspection + ``/external/sync/*``). The first-party
  22. ``/api/v1/sync/*`` surface used by the old Supabase flow is NOT reachable with
  23. these tokens.
  24. Cloudflare fronts ``api.orcaslicer.com`` and blocks unusual User-Agents
  25. (``python-urllib`` gets a ``403 "error code: 1010"``); an honest
  26. ``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
  27. """
  28. from __future__ import annotations
  29. import json
  30. import logging
  31. import os
  32. from datetime import datetime, timedelta, timezone
  33. from typing import Any
  34. import httpx
  35. logger = logging.getLogger(__name__)
  36. # ---------------------------------------------------------------------------
  37. # Endpoints + client identity (env-overridable so staging can be targeted
  38. # without a code change). Defaults point at production.
  39. # ---------------------------------------------------------------------------
  40. _DEFAULT_API_BASE = "https://api.orcaslicer.com"
  41. # Base for both the OAuth endpoints (/oauth/*) and the external API
  42. # (/api/v1/external/*). Override with ORCA_CLOUD_API_BASE to point at
  43. # staging (https://staging-api.orcaslicer.com) during testing.
  44. ORCA_API_BASE = os.environ.get("ORCA_CLOUD_API_BASE", _DEFAULT_API_BASE).rstrip("/")
  45. # Public client id registered with the Orca Cloud team (see the External App
  46. # Pairing developer guide). Not a secret — it appears in browser-visible
  47. # requests — but it must accompany every /oauth/device/code and /oauth/token
  48. # call (incl. refreshes) or the server returns ``invalid_client``. Overridable
  49. # only for the (unlikely) case of a separate staging registration.
  50. ORCA_CLIENT_ID = os.environ.get("ORCA_CLOUD_CLIENT_ID", "oc_app_e873d49ce7dbcc7dca8ba386")
  51. # Scope requested at pairing time. Bambuddy currently only READS the user's
  52. # Orca Cloud profiles (list + view), so we request the minimum — read-only.
  53. # ``sync:read`` grants pull + versions; bump to ``sync:write`` here if/when a
  54. # push-to-cloud feature lands (which forces existing users to re-pair, since
  55. # the granted scope is baked into the issued token).
  56. ORCA_SCOPE = os.environ.get("ORCA_CLOUD_SCOPE", "sync:read")
  57. # Honest client identity. Same posture as the Bambu Cloud client: identifies
  58. # Bambuddy without impersonating Orca's desktop client. Also the thing that
  59. # clears Cloudflare's User-Agent gate in front of the API.
  60. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  61. # Refresh the access token when it has less than this much life left, so a
  62. # slow downstream API call doesn't expire the token mid-flight.
  63. _REFRESH_LEEWAY = timedelta(minutes=5)
  64. # How long a device-code pairing attempt stays valid before the user must
  65. # restart. The server also enforces this (``expires_in`` on the device-code
  66. # response is 600s); we mirror it client-side so we stop polling a dead code.
  67. DEVICE_CODE_TTL = timedelta(minutes=10)
  68. # ---------------------------------------------------------------------------
  69. # Device-poll outcomes
  70. # ---------------------------------------------------------------------------
  71. class DevicePoll:
  72. """String outcomes of one :meth:`OrcaCloudService.poll_token` attempt.
  73. ``PENDING`` / ``SLOW_DOWN`` are non-terminal (keep polling; on SLOW_DOWN
  74. widen the interval). ``DENIED`` / ``EXPIRED`` are terminal — the pairing
  75. attempt is dead and the user must restart. ``COMPLETE`` means tokens were
  76. issued and applied to the service."""
  77. PENDING = "authorization_pending"
  78. SLOW_DOWN = "slow_down"
  79. DENIED = "access_denied"
  80. EXPIRED = "expired_token"
  81. COMPLETE = "complete"
  82. #: Non-terminal — the frontend should poll again.
  83. ONGOING = frozenset({PENDING, SLOW_DOWN})
  84. #: Terminal failure — the frontend should restart the flow.
  85. TERMINAL = frozenset({DENIED, EXPIRED})
  86. class OrcaCloudError(Exception):
  87. """Base exception for Orca Cloud errors (network / unexpected server)."""
  88. pass
  89. class OrcaCloudAuthError(OrcaCloudError):
  90. """Authentication / token-related errors. The caller should typically
  91. prompt the user to reconnect — neither a fresh access token nor a refresh
  92. will recover without re-pairing."""
  93. pass
  94. _shared_http_client: httpx.AsyncClient | None = None
  95. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  96. """Register an app-scoped ``httpx.AsyncClient`` so per-request
  97. ``OrcaCloudService`` instances can reuse its connection pool. Mirrors the
  98. pattern used by :mod:`backend.app.services.bambu_cloud`."""
  99. global _shared_http_client
  100. _shared_http_client = client
  101. # ---------------------------------------------------------------------------
  102. # Service class
  103. # ---------------------------------------------------------------------------
  104. class OrcaCloudService:
  105. """Stateful per-request client for the Orca Cloud external API.
  106. Instantiated by the route layer, populated with a stored token via
  107. :meth:`set_tokens`, then used to call the sync endpoints. Token rotation
  108. on refresh is the route layer's responsibility (see :meth:`refresh` —
  109. mutates ``self`` and returns the new pair, but does NOT persist).
  110. """
  111. def __init__(self, client: httpx.AsyncClient | None = None):
  112. self.access_token: str | None = None
  113. self.refresh_token: str | None = None
  114. self.token_expiry: datetime | None = None
  115. # Mirror the bambu_cloud pattern for client ownership: prefer injected
  116. # client (tests), fall back to app-scoped shared client (production),
  117. # else create our own so ad-hoc scripts still work.
  118. if client is not None:
  119. self._client = client
  120. self._owns_client = False
  121. elif _shared_http_client is not None:
  122. self._client = _shared_http_client
  123. self._owns_client = False
  124. else:
  125. self._client = httpx.AsyncClient(timeout=30.0)
  126. self._owns_client = True
  127. @property
  128. def is_authenticated(self) -> bool:
  129. """True iff we have an access token that won't expire within
  130. :data:`_REFRESH_LEEWAY`. The leeway prevents a slow API call from
  131. timing out mid-flight on a token that was nominally still valid."""
  132. if not self.access_token:
  133. return False
  134. if self.token_expiry is None:
  135. # No expiry recorded — pessimistically treat as expired so the
  136. # caller refreshes before use.
  137. return False
  138. return datetime.now(timezone.utc) + _REFRESH_LEEWAY < self.token_expiry
  139. def set_tokens(
  140. self,
  141. access_token: str | None,
  142. refresh_token: str | None,
  143. expires_at: datetime | None,
  144. ) -> None:
  145. """Hydrate the service from stored credentials."""
  146. self.access_token = access_token
  147. self.refresh_token = refresh_token
  148. # Normalize to timezone-aware UTC so subsequent comparisons against
  149. # ``datetime.now(timezone.utc)`` are well-defined. asyncpg returns
  150. # naive datetimes from a ``TIMESTAMP WITHOUT TIME ZONE`` column —
  151. # we treat naive values as UTC since that's how we stored them.
  152. if expires_at is not None and expires_at.tzinfo is None:
  153. expires_at = expires_at.replace(tzinfo=timezone.utc)
  154. self.token_expiry = expires_at
  155. def clear_tokens(self) -> None:
  156. """Forget all credentials. Used on logout and after auth failures."""
  157. self.access_token = None
  158. self.refresh_token = None
  159. self.token_expiry = None
  160. def _api_headers(self) -> dict[str, str]:
  161. """Headers for calls to the external API. Requires a bearer token —
  162. callers should ensure the service is authenticated first."""
  163. if not self.access_token:
  164. raise OrcaCloudAuthError("Orca Cloud API requires an access token")
  165. return {
  166. "User-Agent": _USER_AGENT,
  167. "Authorization": f"Bearer {self.access_token}",
  168. "Accept": "application/json",
  169. }
  170. # ------------------------------------------------------------------
  171. # Device authorization grant (RFC 8628)
  172. # ------------------------------------------------------------------
  173. async def request_device_code(
  174. self,
  175. scope: str = ORCA_SCOPE,
  176. instance_url: str | None = None,
  177. instance_label: str | None = None,
  178. ) -> dict[str, Any]:
  179. """Start a pairing attempt. Returns the raw device-code response
  180. (``device_code``, ``user_code``, ``verification_uri``,
  181. ``verification_uri_complete``, ``expires_in``, ``interval``).
  182. ``instance_url`` / ``instance_label`` are display-only fields shown on
  183. the user's approval card (anti-phishing context). The ``device_code``
  184. is a secret the caller must keep server-side; only ``user_code`` and
  185. the verification URIs are safe to show the user."""
  186. url = f"{ORCA_API_BASE}/oauth/device/code"
  187. form: dict[str, str] = {"client_id": ORCA_CLIENT_ID, "scope": scope}
  188. if instance_url:
  189. form["instance_url"] = instance_url
  190. if instance_label:
  191. form["instance_label"] = instance_label
  192. try:
  193. resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
  194. except httpx.HTTPError as e:
  195. raise OrcaCloudError(f"Network error requesting Orca Cloud device code: {e}") from e
  196. if resp.status_code >= 400:
  197. detail = _describe_token_error(resp)
  198. # invalid_client means our client_id is wrong / unregistered — an
  199. # operator misconfiguration, not something the user can fix.
  200. if resp.status_code in (400, 401, 403):
  201. raise OrcaCloudAuthError(f"Orca Cloud rejected the device-code request: {detail}")
  202. raise OrcaCloudError(f"Orca Cloud device-code request failed ({resp.status_code}): {detail}")
  203. return resp.json()
  204. async def poll_token(self, device_code: str) -> tuple[str, dict[str, Any] | None]:
  205. """Poll the token endpoint once for a pending device-code grant.
  206. Returns ``(status, data)`` where ``status`` is a :class:`DevicePoll`
  207. value. On :data:`DevicePoll.COMPLETE` the service is mutated with the
  208. new tokens and ``data`` is the raw token response (so the caller can
  209. persist it); otherwise ``data`` is ``None``.
  210. Raises :class:`OrcaCloudError` only for genuinely unexpected responses
  211. (5xx, network, or an unrecognized error code) — the four RFC error
  212. codes are returned as statuses, not raised, because they're normal
  213. control flow for a polling loop."""
  214. url = f"{ORCA_API_BASE}/oauth/token"
  215. form = {
  216. "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
  217. "device_code": device_code,
  218. "client_id": ORCA_CLIENT_ID,
  219. }
  220. try:
  221. resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
  222. except httpx.HTTPError as e:
  223. raise OrcaCloudError(f"Network error polling Orca Cloud token endpoint: {e}") from e
  224. if resp.status_code < 400:
  225. data = resp.json()
  226. self._apply_token_response(data)
  227. return DevicePoll.COMPLETE, data
  228. # RFC 8628 error bodies: {"error": "authorization_pending" | ...}.
  229. error = _error_code(resp)
  230. if error == "authorization_pending":
  231. return DevicePoll.PENDING, None
  232. if error == "slow_down":
  233. return DevicePoll.SLOW_DOWN, None
  234. if error == "access_denied":
  235. return DevicePoll.DENIED, None
  236. # expired_token and invalid_grant both mean "this device code is dead,
  237. # start over" — collapse them to a single terminal EXPIRED status.
  238. if error in ("expired_token", "invalid_grant"):
  239. return DevicePoll.EXPIRED, None
  240. raise OrcaCloudError(f"Orca Cloud token poll failed ({resp.status_code}): {_describe_token_error(resp)}")
  241. async def refresh(self) -> dict[str, Any]:
  242. """Use the stored refresh token to obtain a fresh access/refresh pair.
  243. Refresh tokens are single-use — the old one is consumed the moment
  244. this succeeds. The caller MUST persist the new pair atomically; a
  245. crash between this return and the DB write strands the user (though
  246. Orca's ~60s grace window means a *replay* of the old token within that
  247. window still yields a working pair rather than revoking). Returns the
  248. raw token-response dict so the caller has the full new pair."""
  249. if not self.refresh_token:
  250. raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
  251. url = f"{ORCA_API_BASE}/oauth/token"
  252. form = {
  253. "grant_type": "refresh_token",
  254. "refresh_token": self.refresh_token,
  255. "client_id": ORCA_CLIENT_ID,
  256. }
  257. try:
  258. resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
  259. except httpx.HTTPError as e:
  260. raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
  261. if resp.status_code >= 400:
  262. detail = _describe_token_error(resp)
  263. # 400 invalid_grant on refresh = expired / already-used / the user
  264. # disconnected us. Unrecoverable — clear and force a re-pair.
  265. if resp.status_code in (400, 401, 403):
  266. self.clear_tokens()
  267. raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
  268. raise OrcaCloudError(f"Orca Cloud refresh failed ({resp.status_code}): {detail}")
  269. data = resp.json()
  270. self._apply_token_response(data)
  271. return data
  272. def _apply_token_response(self, data: dict[str, Any]) -> None:
  273. """Update ``self.access_token`` / ``self.refresh_token`` /
  274. ``self.token_expiry`` from a token-response payload. Caller is still
  275. responsible for persisting the values to the DB."""
  276. access = data.get("access_token")
  277. refresh = data.get("refresh_token")
  278. expires_in = data.get("expires_in")
  279. if not access:
  280. raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
  281. self.access_token = access
  282. # The token endpoint always rotates the refresh token; if a response
  283. # omits one we keep the previous value to avoid stranding the session,
  284. # but that shouldn't happen in practice.
  285. if refresh:
  286. self.refresh_token = refresh
  287. if isinstance(expires_in, (int, float)) and expires_in > 0:
  288. self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
  289. else:
  290. self.token_expiry = None
  291. # ------------------------------------------------------------------
  292. # External API
  293. # ------------------------------------------------------------------
  294. async def introspect(self) -> dict[str, Any]:
  295. """Return the pairing's introspection record (``user_id``,
  296. ``client_id``, ``connection_id``, ``scope``, ``expires_at``). Used
  297. after pairing to record the user's id for display in Bambuddy's UI."""
  298. url = f"{ORCA_API_BASE}/api/v1/external-apps/me"
  299. try:
  300. resp = await self._client.get(url, headers=self._api_headers())
  301. except httpx.HTTPError as e:
  302. raise OrcaCloudError(f"Network error fetching Orca Cloud introspection: {e}") from e
  303. if resp.status_code == 401:
  304. raise OrcaCloudAuthError("Orca Cloud introspection unauthorized — token expired or revoked")
  305. if resp.status_code >= 400:
  306. raise OrcaCloudError(f"Orca Cloud introspection failed ({resp.status_code}): {resp.text[:200]}")
  307. return resp.json()
  308. async def list_profiles(self) -> list[dict[str, Any]]:
  309. """Return the user's Orca Cloud profiles as a flat list of profile
  310. entries (``{id, name, content, updated_time, created_time}``) —
  311. forwarded verbatim; callers pick the fields they need.
  312. Uses ``GET /api/v1/external/sync/pull`` with NO ``?cursor=`` parameter,
  313. the documented "full snapshot" bootstrap. Sending ``cursor=0`` instead
  314. trips ``410 cursor_too_old`` (the sync log doesn't reach back to the
  315. Unix epoch). The pull response is ``{next_cursor, upserts, deletes}``;
  316. we return ``upserts`` and ignore the rest (no prior client state to
  317. invalidate on a read-only list)."""
  318. url = f"{ORCA_API_BASE}/api/v1/external/sync/pull"
  319. try:
  320. resp = await self._client.get(url, headers=self._api_headers())
  321. except httpx.HTTPError as e:
  322. raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
  323. if resp.status_code == 401:
  324. raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
  325. if resp.status_code == 410:
  326. # cursor_too_old on a no-cursor request would be surprising, but
  327. # surface it clearly rather than as an opaque 502.
  328. raise OrcaCloudError("Orca Cloud sync cursor too old — a full resync is required")
  329. if resp.status_code >= 400:
  330. raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
  331. data = resp.json()
  332. if isinstance(data, dict):
  333. upserts = data.get("upserts")
  334. if isinstance(upserts, list):
  335. return upserts
  336. # Tolerate a flat-list shape if Orca ever rolls one out here.
  337. for key in ("profiles", "data"):
  338. value = data.get(key)
  339. if isinstance(value, list):
  340. return value
  341. if isinstance(data, list):
  342. return data
  343. logger.warning("Orca Cloud /external/sync/pull returned unexpected shape: %r", type(data).__name__)
  344. return []
  345. async def get_profile(self, profile_id: str) -> dict[str, Any]:
  346. """Fetch a single profile's full content. The external sync API has no
  347. per-profile GET, so we list and filter. For the realistic profile
  348. counts this is fine; if it becomes a hot path we'll add caching at the
  349. route layer rather than hammer the pull endpoint."""
  350. profiles = await self.list_profiles()
  351. for profile in profiles:
  352. if str(profile.get("id")) == str(profile_id):
  353. return profile
  354. raise OrcaCloudError(f"Orca Cloud profile {profile_id!r} not found (scanned {len(profiles)} profiles)")
  355. # ------------------------------------------------------------------
  356. # Lifecycle
  357. # ------------------------------------------------------------------
  358. async def close(self) -> None:
  359. """Release the underlying httpx client iff we own it. No-op if we're
  360. using an injected or app-shared client (those are managed elsewhere)."""
  361. if self._owns_client:
  362. await self._client.aclose()
  363. def _error_code(resp: httpx.Response) -> str | None:
  364. """Extract the RFC-style ``error`` code from a token-endpoint error body,
  365. or ``None`` if the body doesn't parse as ``{"error": "..."}``."""
  366. try:
  367. data = resp.json()
  368. except (json.JSONDecodeError, ValueError):
  369. return None
  370. if isinstance(data, dict):
  371. err = data.get("error")
  372. if isinstance(err, str) and err:
  373. return err
  374. return None
  375. def _describe_token_error(resp: httpx.Response) -> str:
  376. """Best-effort extraction of a user-facing message from a token-endpoint
  377. error response. Tries JSON fields in order; falls back to the raw body
  378. (truncated) if nothing parses."""
  379. try:
  380. data = resp.json()
  381. except (json.JSONDecodeError, ValueError):
  382. return (resp.text or "<empty body>")[:200]
  383. if not isinstance(data, dict):
  384. return str(data)[:200]
  385. for key in ("error_description", "msg", "error", "message"):
  386. val = data.get(key)
  387. if isinstance(val, str) and val:
  388. return val
  389. return str(data)[:200]