devora-fastapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: devora-fastapi
3
+ Version: 0.1.0
4
+ Summary: FastAPI adapter for the Devora Python backend SDK
5
+ Project-URL: Documentation, https://docs.devora.sh
6
+ Project-URL: Repository, https://github.com/getdevora/devora-sdks
7
+ Project-URL: Issues, https://github.com/getdevora/devora-sdks/issues
8
+ Author: Devora
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: devora,fastapi,impersonation,sdk,security
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: <4.0,>=3.10
23
+ Requires-Dist: anyio>=4.14.2
24
+ Requires-Dist: devora-python<0.2.0,>=0.1.0
25
+ Requires-Dist: fastapi<1.0.0,>=0.133.0
26
+ Requires-Dist: idna>=3.15
27
+ Requires-Dist: starlette>=1.3.1
28
+ Description-Content-Type: text/markdown
29
+
30
+ # devora-fastapi
31
+
32
+ Recording, masking and activity preferences are configured in Devora Settings.
33
+ SDK initialization overrides are ignored. New sessions retain the server's policy
34
+ snapshot across exchange and resume. Developer privacy labels take effect only
35
+ when selected in Settings; sensitive-field protection remains mandatory.
36
+ See [migration details](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
37
+
38
+ FastAPI adapter for the Devora Python backend SDK.
39
+
40
+ ## Requirements
41
+
42
+ - Python `>=3.10,<4.0`
43
+ - FastAPI `>=0.100.0,<1.0.0`
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install devora-python devora-fastapi
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ```python
54
+ from fastapi import FastAPI
55
+ from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
56
+ from devora_sdk_fastapi import fastapi_router
57
+
58
+ sdk = devora_sdk(
59
+ api_key="pk_server_live_...",
60
+ secret_key="sk_server_live_...",
61
+ org_id="org_...",
62
+ )
63
+
64
+
65
+ @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
66
+ async def search_users(req):
67
+ return {"users": await search_customer_users(req.query.get("term", ""))}
68
+
69
+
70
+ app = FastAPI()
71
+ app.include_router(fastapi_router(sdk), prefix="/devora")
72
+ ```
73
+
74
+ For protected application routes, install the guard middleware and extract a
75
+ trusted impersonation context from authenticated request state.
76
+
77
+ ```python
78
+ from devora_sdk import ImpersonationContext
79
+ from devora_sdk_fastapi import DevoraImpersonationGuardMiddleware
80
+
81
+
82
+ def get_impersonation_context(request) -> ImpersonationContext | None:
83
+ devora = getattr(request.state, "devora", None)
84
+ if not devora:
85
+ return None
86
+ return ImpersonationContext(
87
+ is_impersonation=devora["isImpersonation"],
88
+ scope=devora["scope"],
89
+ session_id=devora["sessionId"],
90
+ expires_at=devora["expiresAt"],
91
+ actor=devora["actor"],
92
+ subject=devora["subject"],
93
+ auth_method=devora["authMethod"],
94
+ authorization_source=devora["authorizationSource"],
95
+ recording_allowed=devora["recordingAllowed"],
96
+ impersonator=devora.get("impersonator"),
97
+ )
98
+
99
+
100
+ app.add_middleware(
101
+ DevoraImpersonationGuardMiddleware,
102
+ sdk=sdk,
103
+ get_impersonation_context=get_impersonation_context,
104
+ )
105
+ ```
106
+
107
+ Register your own authentication middleware **after** this call — Starlette
108
+ runs middleware in the reverse of its registration order, so it must be the
109
+ outer layer that runs first for `request.state` to carry verified claims by
110
+ the time the guard reads them.
111
+
112
+ Resolve method overrides and route rewrites **before** the guard as well
113
+ (register that middleware after this call too, so it runs first). The guard
114
+ also judges every `X-HTTP-Method-Override`, `X-HTTP-Method` and
115
+ `X-Method-Override` value and any `_method` query parameter, but it cannot see a
116
+ `_method` field inside a request body, and it judges the path it receives.
117
+
118
+ `expires_at` must be a Unix timestamp in milliseconds. If your JWT stores Unix
119
+ seconds, multiply by `1000` when building the impersonation context. `actor`,
120
+ `subject`, `auth_method`, `authorization_source`, and `recording_allowed` are
121
+ all required — the guard rejects the context as invalid without them, even
122
+ though the dataclass marks them optional for construction convenience.
@@ -0,0 +1,7 @@
1
+ devora_sdk_fastapi/__init__.py,sha256=9IpGyO7wOWZA8LMn4HtLrPQERyWYH8x6Y_w8ChibCa8,336
2
+ devora_sdk_fastapi/adapter.py,sha256=_cdjN_w_xZStRAeqCZL0W_SfOrAsCqBGYpcbitmaTLQ,15963
3
+ devora_sdk_fastapi/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ devora_fastapi-0.1.0.dist-info/METADATA,sha256=MpK8yOtaGYDvyUPUzTfXYhKVXlGhSZOosT58l0yxTKU,4280
5
+ devora_fastapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ devora_fastapi-0.1.0.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
7
+ devora_fastapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ from .adapter import (
2
+ browser_session_router,
3
+ DevoraImpersonationGuardMiddleware,
4
+ FastAPIAdapterOptions,
5
+ fastapi_adapter,
6
+ fastapi_route_path,
7
+ fastapi_router,
8
+ )
9
+
10
+ __all__ = [
11
+ "browser_session_router",
12
+ "DevoraImpersonationGuardMiddleware",
13
+ "FastAPIAdapterOptions",
14
+ "fastapi_adapter",
15
+ "fastapi_route_path",
16
+ "fastapi_router",
17
+ ]
@@ -0,0 +1,462 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import asyncio
5
+ import json
6
+ import re
7
+ import warnings
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Awaitable, Callable, Mapping, Optional, Union
10
+
11
+ from devora_sdk.guard import _validate_context
12
+ from devora_sdk import (
13
+ AdapterRequest,
14
+ ImpersonationContext,
15
+ InvalidImpersonationContext,
16
+ ProcessRequestOptions,
17
+ route_relative_path,
18
+ strict_encode,
19
+ SessionLivenessChecker,
20
+ async_process_request,
21
+ coerce_impersonation_context,
22
+ evaluate_impersonation_guard,
23
+ policy_methods,
24
+ validate_timestamp_tolerance,
25
+ )
26
+ from devora_sdk.models import SDKRoute
27
+ from devora_sdk.utils import create_error_response, get_error_status_code
28
+ from starlette.requests import Request as _StarletteRequest
29
+
30
+
31
+ def _private_json_response(*args: Any, **kwargs: Any) -> Any:
32
+ from fastapi.responses import JSONResponse
33
+
34
+ response = JSONResponse(*args, **kwargs)
35
+ response.headers["Cache-Control"] = "private, no-store"
36
+ return response
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FastAPIAdapterOptions:
41
+ timestamp_tolerance: Optional[int] = None
42
+ max_body_size: Optional[int] = None
43
+
44
+ def __post_init__(self) -> None:
45
+ validate_timestamp_tolerance(self.timestamp_tolerance)
46
+
47
+
48
+ ContextGetter = Callable[[Any], Union[Optional[ImpersonationContext], Awaitable[Optional[ImpersonationContext]]]]
49
+ BlockedCallback = Callable[[Any, ImpersonationContext], Union[None, Awaitable[None]]]
50
+
51
+
52
+ def fastapi_router(sdk: Any, options: Optional[FastAPIAdapterOptions] = None) -> Any:
53
+ from fastapi import APIRouter
54
+
55
+ adapter_options = options or FastAPIAdapterOptions()
56
+ routes = sdk.get_routes()
57
+ router = APIRouter()
58
+
59
+ for route in routes:
60
+ router.add_api_route(
61
+ fastapi_route_path(route.path),
62
+ _endpoint_for_route(sdk, routes, route, adapter_options),
63
+ methods=[route.method],
64
+ include_in_schema=False,
65
+ name=_route_name(route),
66
+ )
67
+
68
+ return router
69
+
70
+
71
+ def fastapi_adapter(sdk: Any, options: Optional[FastAPIAdapterOptions] = None) -> Any:
72
+ return fastapi_router(sdk, options)
73
+
74
+
75
+ def fastapi_route_path(path: str) -> str:
76
+ return re.sub(r":([A-Za-z_][A-Za-z0-9_]*)", r"{\1}", path)
77
+
78
+
79
+ def _endpoint_for_route(
80
+ sdk: Any,
81
+ routes: list[SDKRoute],
82
+ route: SDKRoute,
83
+ options: FastAPIAdapterOptions,
84
+ ) -> Callable[[Any], Awaitable[Any]]:
85
+ from fastapi import Request
86
+ from fastapi.responses import JSONResponse
87
+
88
+ async def endpoint(request: Any) -> Any:
89
+ try:
90
+ max_body_size = (
91
+ options.max_body_size
92
+ if options.max_body_size is not None
93
+ else ProcessRequestOptions.max_body_size
94
+ )
95
+ content_length = request.headers.get("content-length")
96
+ if content_length is not None:
97
+ try:
98
+ declared_size = int(content_length)
99
+ except ValueError:
100
+ declared_size = None
101
+ if declared_size is not None and declared_size > max_body_size:
102
+ return _private_json_response(
103
+ create_error_response("Request body too large", "BODY_TOO_LARGE"),
104
+ status_code=get_error_status_code("BODY_TOO_LARGE"),
105
+ )
106
+ path = route_relative_path(_wire_path(request), route.path)
107
+ if path is None:
108
+ return _private_json_response(create_error_response("Not found", "NOT_FOUND"), status_code=404)
109
+ raw_query = request.scope.get("query_string", b"")
110
+ adapter_request = AdapterRequest(
111
+ method=request.method,
112
+ path=path,
113
+ query=bytes(raw_query).decode("latin-1"),
114
+ body=await _read_bounded_body(request, max_body_size),
115
+ # Raw pairs keep duplicate headers visible to the verifier.
116
+ headers=list(request.headers.raw),
117
+ )
118
+ response = await async_process_request(
119
+ sdk,
120
+ routes,
121
+ adapter_request,
122
+ ProcessRequestOptions(
123
+ timestamp_tolerance=options.timestamp_tolerance,
124
+ max_body_size=max_body_size,
125
+ ),
126
+ )
127
+ status_code = 200 if response.get("success") else get_error_status_code(response.get("errorCode"))
128
+ return _private_json_response(response, status_code=status_code)
129
+ except _BodyReadError as error:
130
+ return _private_json_response(create_error_response(str(error), error.code), status_code=error.status)
131
+ except Exception:
132
+ return _private_json_response(
133
+ {
134
+ "success": False,
135
+ "errorCode": "INTERNAL_ERROR",
136
+ "error": "An unexpected error occurred",
137
+ },
138
+ status_code=500,
139
+ )
140
+
141
+ endpoint.__annotations__ = {"request": Request, "return": JSONResponse}
142
+ return endpoint
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class _GuardOptions:
147
+ sdk: Any
148
+ get_impersonation_context: ContextGetter
149
+ on_blocked: Optional[BlockedCallback] = None
150
+ blocked_response: Mapping[str, Any] = field(default_factory=dict)
151
+ show_warnings: bool = False
152
+ enforce_liveness: bool = True
153
+ liveness_cache_ttl_ms: int = 5_000
154
+ on_liveness_unavailable: str = "deny"
155
+ is_impersonation_allowed: Optional[Callable[[Any, ImpersonationContext], Union[bool, Awaitable[bool]]]] = None
156
+ bridge_path: Optional[str] = None
157
+
158
+
159
+ class DevoraImpersonationGuardMiddleware:
160
+ """Starlette/FastAPI middleware enforcing the Devora impersonation policy.
161
+
162
+ ``get_impersonation_context`` may be sync or async and returns ``None`` for
163
+ ordinary traffic, or a dict / ``ImpersonationContext``. Any other value fails
164
+ closed with a 500. Policy patterns match the full client-visible path
165
+ (``scope["path"]``, including ``root_path``); deny rules also match the path
166
+ relative to ``root_path``. ``bridge_path`` enables the read-scope allowance
167
+ for a mounted browser-session bridge (off by default).
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ app: Any,
173
+ sdk: Any,
174
+ get_impersonation_context: ContextGetter,
175
+ on_blocked: Optional[BlockedCallback] = None,
176
+ blocked_response: Optional[Mapping[str, Any]] = None,
177
+ show_warnings: bool = False,
178
+ enforce_liveness: bool = True,
179
+ liveness_cache_ttl_ms: int = 5_000,
180
+ on_liveness_unavailable: str = "deny",
181
+ is_impersonation_allowed: Optional[
182
+ Callable[[Any, ImpersonationContext], Union[bool, Awaitable[bool]]]
183
+ ] = None,
184
+ bridge_path: Optional[str] = None,
185
+ ) -> None:
186
+ from starlette.middleware.base import BaseHTTPMiddleware
187
+
188
+ self._middleware = BaseHTTPMiddleware(
189
+ app,
190
+ dispatch=self.dispatch,
191
+ )
192
+ self.options = _GuardOptions(
193
+ sdk=sdk,
194
+ get_impersonation_context=get_impersonation_context,
195
+ on_blocked=on_blocked,
196
+ blocked_response=blocked_response or {},
197
+ show_warnings=show_warnings,
198
+ enforce_liveness=enforce_liveness,
199
+ liveness_cache_ttl_ms=liveness_cache_ttl_ms,
200
+ on_liveness_unavailable=on_liveness_unavailable,
201
+ is_impersonation_allowed=is_impersonation_allowed,
202
+ bridge_path=bridge_path,
203
+ )
204
+ self._liveness = (
205
+ SessionLivenessChecker(sdk, liveness_cache_ttl_ms, on_liveness_unavailable)
206
+ if enforce_liveness
207
+ else None
208
+ )
209
+
210
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
211
+ await self._middleware(scope, receive, send)
212
+
213
+ async def dispatch(self, request: Any, call_next: Callable[[Any], Awaitable[Any]]) -> Any:
214
+ from fastapi.responses import JSONResponse
215
+
216
+ try:
217
+ blocked = await self._decide(request)
218
+ except Exception:
219
+ # Extractor, policy and hook failures fail closed.
220
+ blocked = _private_json_response(
221
+ create_error_response("Impersonation guard failed", "INTERNAL_ERROR"), status_code=500
222
+ )
223
+ return blocked if blocked is not None else await call_next(request)
224
+
225
+ async def _decide(self, request: Any) -> Optional[Any]:
226
+ """Return a blocking JSONResponse, or None to continue."""
227
+ from fastapi.responses import JSONResponse
228
+ from starlette.concurrency import run_in_threadpool
229
+
230
+ try:
231
+ context = coerce_impersonation_context(
232
+ await _maybe_await(self.options.get_impersonation_context(request))
233
+ )
234
+ except InvalidImpersonationContext:
235
+ return _private_json_response(
236
+ create_error_response("Invalid impersonation context", "INVALID_IMPERSONATION_CONTEXT"),
237
+ status_code=500,
238
+ )
239
+ # Non-impersonation traffic passes through without policy or liveness lookups.
240
+ if not context or not context.is_impersonation:
241
+ return None
242
+ session_live: Optional[bool] = None
243
+ liveness_unavailable = False
244
+ if self._liveness and isinstance(context.session_id, str) and context.session_id:
245
+ session_live, liveness_unavailable = await run_in_threadpool(
246
+ self._liveness.check, context.session_id
247
+ )
248
+ policy = await run_in_threadpool(self.options.sdk.get_scope_config)
249
+ # The customer hook is semantic authorization for otherwise-valid, live
250
+ # sessions. Never invoke it for an invalid/expired context, an ended or
251
+ # unverifiable session, or while policy is unavailable.
252
+ semantic_allowed = True
253
+ if (
254
+ self.options.is_impersonation_allowed
255
+ and _validate_context(context)[0]
256
+ and session_live is not False
257
+ and not liveness_unavailable
258
+ and policy is not None
259
+ ):
260
+ semantic_allowed = await _maybe_await(self.options.is_impersonation_allowed(request, context))
261
+ path, aliases = _guard_paths(request)
262
+ decision = evaluate_impersonation_guard(
263
+ request.method,
264
+ path,
265
+ context,
266
+ policy,
267
+ session_live=session_live,
268
+ liveness_unavailable=liveness_unavailable,
269
+ is_impersonation_allowed=(lambda _context: semantic_allowed),
270
+ bridge_path=self.options.bridge_path,
271
+ raw_path=_raw_request_path(request),
272
+ alias_paths=aliases,
273
+ methods=policy_methods(request.method, request.headers, request.url.query),
274
+ )
275
+ if decision.allowed:
276
+ return None
277
+
278
+ # Notify only for actual blocks (scope/endpoint/liveness), not for
279
+ # temporary policy unavailability (503) — matching the Node guard.
280
+ notify_blocked = decision.status_code == 403 or (decision.body or {}).get(
281
+ "errorCode"
282
+ ) == "IMPERSONATION_SESSION_ENDED"
283
+ if self.options.on_blocked and notify_blocked:
284
+ try:
285
+ await _maybe_await(self.options.on_blocked(request, context))
286
+ except Exception:
287
+ pass # Audit callbacks cannot change the decision.
288
+ if self.options.show_warnings:
289
+ warnings.warn(
290
+ f"[Devora] Blocked {request.method} {path} (session: {context.session_id})",
291
+ RuntimeWarning,
292
+ stacklevel=2,
293
+ )
294
+
295
+ status_code, body = _guard_response(
296
+ decision.status_code,
297
+ decision.body,
298
+ self.options.blocked_response,
299
+ )
300
+ return _private_json_response(body, status_code=status_code)
301
+
302
+
303
+ def _route_name(route: SDKRoute) -> str:
304
+ name = route.path.strip("/").replace("/", "_").replace(":", "")
305
+ return f"devora_{route.method.lower()}_{name or 'root'}"
306
+
307
+
308
+ def _raw_request_path(request: Any) -> Optional[str]:
309
+ """The still-percent-encoded path, straight from the ASGI scope.
310
+
311
+ Devora signs path params with ``encodeURIComponent``; Starlette's decoded
312
+ ``request.url.path`` can no longer match that signature for values containing
313
+ reserved characters (``@``, ``+``, space, ...).
314
+ """
315
+ scope = getattr(request, "scope", None)
316
+ if isinstance(scope, Mapping):
317
+ raw = scope.get("raw_path")
318
+ if isinstance(raw, (bytes, bytearray)):
319
+ try:
320
+ return bytes(raw).decode("ascii")
321
+ except UnicodeDecodeError:
322
+ return bytes(raw).decode("latin-1")
323
+ return None
324
+
325
+
326
+ def _routed_request_path(request: Any) -> str:
327
+ """The decoded path Starlette's router actually dispatches on.
328
+
329
+ ``request.url.path`` is re-parsed as a WHATWG URL, which silently strips tab,
330
+ line feed and carriage return; the router matches ``scope["path"]`` where
331
+ those bytes survive. The guard must judge the path the router judges, or a
332
+ ``%09`` inside a whitelisted read path lets a write reach a parameterised
333
+ handler.
334
+ """
335
+ scope = getattr(request, "scope", None)
336
+ if isinstance(scope, Mapping):
337
+ path = scope.get("path")
338
+ if isinstance(path, str):
339
+ return path
340
+ return str(request.url.path)
341
+
342
+
343
+ def _guard_paths(request: Any) -> tuple[str, list[str]]:
344
+ """The full client-visible routed path, plus the root_path-relative view."""
345
+ path = _routed_request_path(request)
346
+ aliases: list[str] = []
347
+ scope = getattr(request, "scope", None)
348
+ root_path = scope.get("root_path", "") if isinstance(scope, Mapping) else ""
349
+ if isinstance(root_path, str) and root_path and path.startswith(root_path):
350
+ aliases.append(path[len(root_path) :] or "/")
351
+ return path, aliases
352
+
353
+
354
+ def _wire_path(request: Any) -> str:
355
+ """ASGI ``raw_path`` as sent; otherwise the strict re-encoded decoded path,
356
+ which reproduces what Devora's strict signer sent and fails closed otherwise."""
357
+ raw = _raw_request_path(request)
358
+ if raw is not None:
359
+ return raw
360
+ return "/".join(strict_encode(part) if part else "" for part in _routed_request_path(request).split("/"))
361
+
362
+
363
+ class _BodyReadError(ValueError):
364
+ def __init__(self, code: str, status: int, message: str):
365
+ super().__init__(message)
366
+ self.code = code
367
+ self.status = status
368
+
369
+
370
+ async def _read_bounded_body(request: Any, max_bytes: int, timeout: float = 5.0) -> bytes:
371
+ if not isinstance(max_bytes, int) or max_bytes < 0 or timeout <= 0:
372
+ raise ValueError("Invalid body budget")
373
+ declared = request.headers.get("content-length")
374
+ if declared is not None:
375
+ if not declared.isascii() or not declared.isdigit():
376
+ raise _BodyReadError("INVALID_BODY", 400, "Invalid Content-Length")
377
+ if int(declared) > max_bytes:
378
+ raise _BodyReadError("BODY_TOO_LARGE", 413, "Request body too large")
379
+
380
+ async def read() -> bytes:
381
+ body = bytearray()
382
+ async for chunk in request.stream():
383
+ if len(body) + len(chunk) > max_bytes:
384
+ raise _BodyReadError("BODY_TOO_LARGE", 413, "Request body too large")
385
+ body.extend(chunk)
386
+ return bytes(body)
387
+
388
+ try:
389
+ return await asyncio.wait_for(read(), timeout=timeout)
390
+ except asyncio.TimeoutError as error:
391
+ raise _BodyReadError("BODY_TIMEOUT", 408, "Request body timed out") from error
392
+
393
+
394
+ async def _maybe_await(value: Any) -> Any:
395
+ if inspect.isawaitable(value):
396
+ return await value
397
+ return value
398
+
399
+
400
+ def _guard_response(
401
+ status_code: int,
402
+ body: Optional[dict[str, object]],
403
+ blocked_response: Mapping[str, Any],
404
+ ) -> tuple[int, dict[str, object]]:
405
+ response_body = dict(body or {})
406
+ if status_code == 403:
407
+ status_code = int(blocked_response.get("status", status_code))
408
+ if response_body.get("errorCode") == "IMPERSONATION_SCOPE_VIOLATION":
409
+ response_body["error"] = blocked_response.get(
410
+ "message", response_body.get("error", "This action is blocked during impersonation")
411
+ )
412
+ response_body["errorCode"] = blocked_response.get(
413
+ "errorCode", response_body.get("errorCode", "IMPERSONATION_SCOPE_VIOLATION")
414
+ )
415
+ return status_code, response_body
416
+
417
+
418
+ def browser_session_router(
419
+ sdk: Any,
420
+ get_impersonation_context: Callable[[Any], Any],
421
+ path: str = "/api/devora/browser-session",
422
+ allowed_origins: Optional[list[str]] = None,
423
+ ) -> Any:
424
+ """Router exposing ``POST <path>`` for the browser-session bridge.
425
+
426
+ Include it on the app that already authenticates requests; the context
427
+ callable is the same one given to the guard middleware.
428
+ """
429
+ from fastapi import APIRouter
430
+ from fastapi.responses import JSONResponse
431
+ from starlette.concurrency import run_in_threadpool
432
+
433
+ from devora_sdk import resolve_browser_session
434
+
435
+ router = APIRouter()
436
+
437
+ # NOTE: the parameter annotation must resolve from module globals because
438
+ # this module uses `from __future__ import annotations`; a function-local
439
+ # `Request` import would make FastAPI treat it as a query parameter.
440
+ @router.post(path)
441
+ async def browser_session(request: _StarletteRequest) -> JSONResponse:
442
+ try:
443
+ body = json.loads(await _read_bounded_body(request, 4096))
444
+ except _BodyReadError as error:
445
+ return _private_json_response(create_error_response(str(error), error.code), status_code=error.status)
446
+ except (ValueError, UnicodeDecodeError):
447
+ return _private_json_response({"success": False, "error": "Invalid JSON body"}, status_code=400)
448
+ try:
449
+ context = coerce_impersonation_context(await _maybe_await(get_impersonation_context(request)))
450
+ except InvalidImpersonationContext:
451
+ return _private_json_response({"status": "blocked", "reason": "session_invalid"}, status_code=500)
452
+ status, payload = await run_in_threadpool(
453
+ resolve_browser_session,
454
+ sdk,
455
+ context,
456
+ (body or {}).get("tabRef") if isinstance(body, dict) else None,
457
+ request.headers.get("origin"),
458
+ allowed_origins,
459
+ )
460
+ return _private_json_response(payload, status_code=status, headers={"Cache-Control": "private, no-store"})
461
+
462
+ return router
@@ -0,0 +1 @@
1
+