devora-django 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,141 @@
1
+ Metadata-Version: 2.5
2
+ Name: devora-django
3
+ Version: 0.1.0
4
+ Summary: Django 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,django,impersonation,sdk,security
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: <4.0,>=3.10
25
+ Requires-Dist: devora-python<0.2.0,>=0.1.0
26
+ Requires-Dist: django!=6.0.0,!=6.0.1,!=6.0.2,!=6.0.3,!=6.0.4,!=6.0.5,!=6.0.6,!=6.0.7,<7.0,>=5.2.17
27
+ Requires-Dist: sqlparse>=0.6.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # devora-django
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
+ Django adapter for the Devora Python backend SDK.
39
+
40
+ ## Requirements
41
+
42
+ - Python `>=3.10,<4.0`
43
+ - Django `>=5.2,<7.0`
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install devora-python devora-django
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ```python
54
+ # devora_integration.py
55
+ from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
56
+
57
+ sdk = devora_sdk(
58
+ api_key="pk_server_live_...",
59
+ secret_key="sk_server_live_...",
60
+ org_id="org_...",
61
+ )
62
+
63
+
64
+ @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
65
+ def search_users(req):
66
+ return {"users": search_customer_users(req.query.get("term", ""))}
67
+ ```
68
+
69
+ ```python
70
+ # urls.py
71
+ from django.urls import include, path
72
+ from devora_sdk_django import django_urlpatterns
73
+ from .devora_integration import sdk
74
+
75
+ urlpatterns = [
76
+ path("devora/", include(django_urlpatterns(sdk))),
77
+ ]
78
+ ```
79
+
80
+ Use `async_django_urlpatterns(sdk)` instead when your exposed handlers are
81
+ async functions.
82
+
83
+ For protected application routes, install the guard middleware and extract a
84
+ trusted impersonation context from your authenticated request state.
85
+
86
+ ```python
87
+ from devora_sdk import ImpersonationContext
88
+ from devora_sdk_django import create_impersonation_guard
89
+ from .devora_integration import sdk
90
+
91
+
92
+ def get_impersonation_context(request):
93
+ devora = getattr(request.user, "devora", None)
94
+ if not devora:
95
+ return None
96
+ return ImpersonationContext(
97
+ is_impersonation=devora["isImpersonation"],
98
+ scope=devora["scope"],
99
+ session_id=devora["sessionId"],
100
+ expires_at=devora["expiresAt"],
101
+ actor=devora["actor"],
102
+ subject=devora["subject"],
103
+ auth_method=devora["authMethod"],
104
+ authorization_source=devora["authorizationSource"],
105
+ recording_allowed=devora["recordingAllowed"],
106
+ impersonator=devora.get("impersonator"),
107
+ )
108
+
109
+
110
+ DevoraGuardMiddleware = create_impersonation_guard(
111
+ sdk=sdk,
112
+ get_impersonation_context=get_impersonation_context,
113
+ )
114
+ ```
115
+
116
+ Add `"yourapp.devora_integration.DevoraGuardMiddleware"` to `settings.MIDDLEWARE`,
117
+ after your own authentication middleware.
118
+
119
+ `expires_at` must be a Unix timestamp in milliseconds. If your JWT stores Unix
120
+ seconds, multiply by `1000` when building the impersonation context. `actor`,
121
+ `subject`, `auth_method`, `authorization_source`, and `recording_allowed` are
122
+ all required — the guard rejects the context as invalid without them, even
123
+ though the dataclass marks them optional for construction convenience.
124
+
125
+ ## Cross-Origin-Opener-Policy
126
+
127
+ Django's `SecurityMiddleware` sends `Cross-Origin-Opener-Policy: same-origin`
128
+ by default. That cuts the page opened by a Devora impersonation link off from
129
+ the dashboard tab that opened it, so the link cannot be redeemed. Set
130
+ `SECURE_CROSS_ORIGIN_OPENER_POLICY = None`, or send `unsafe-none` on the
131
+ route your Devora links land on.
132
+
133
+ ## Request body limits
134
+
135
+ SDK views read at most `max_body_size + 1` bytes before parsing (1 MiB by default)
136
+ and reject oversized declared lengths without reading. The browser-session view
137
+ uses a 4 KiB limit. Async SDK views perform this bounded read off the event loop.
138
+ Set `DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024` and keep earlier middleware from
139
+ buffering larger SDK bodies. WSGI/ASGI servers and reverse proxies also need
140
+ request-size and read-timeout limits: Django's ASGI handler can spool a request
141
+ before a view runs, and the SDK cannot bound that prior allocation.
@@ -0,0 +1,7 @@
1
+ devora_sdk_django/__init__.py,sha256=ReVeIQS3W9GYBqGhLJ93nhZhSeuRN1MZFWRbmY5pzw4,450
2
+ devora_sdk_django/adapter.py,sha256=dp3xyjrAlktD-qKbQbDjXK-8R5dMc6Yje9CnNVe4k8o,18934
3
+ devora_sdk_django/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ devora_django-0.1.0.dist-info/METADATA,sha256=pHxBmc0ZmFeGUUi-x9YJPbWJiGvGQbyY0E9Q4acTxBE,4951
5
+ devora_django-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ devora_django-0.1.0.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
7
+ devora_django-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,21 @@
1
+ from .adapter import (
2
+ browser_session_view,
3
+ DjangoAdapterOptions,
4
+ DjangoImpersonationGuardOptions,
5
+ async_django_urlpatterns,
6
+ create_impersonation_guard,
7
+ django_adapter,
8
+ django_route_pattern,
9
+ django_urlpatterns,
10
+ )
11
+
12
+ __all__ = [
13
+ "browser_session_view",
14
+ "DjangoAdapterOptions",
15
+ "DjangoImpersonationGuardOptions",
16
+ "async_django_urlpatterns",
17
+ "create_impersonation_guard",
18
+ "django_adapter",
19
+ "django_route_pattern",
20
+ "django_urlpatterns",
21
+ ]
@@ -0,0 +1,559 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import re
6
+ import warnings
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable, Mapping, Optional
9
+
10
+ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
11
+ from devora_sdk import (
12
+ AdapterRequest,
13
+ ImpersonationContext,
14
+ InvalidImpersonationContext,
15
+ route_relative_path,
16
+ strict_encode,
17
+ ProcessRequestOptions,
18
+ SessionLivenessChecker,
19
+ async_process_request,
20
+ coerce_impersonation_context,
21
+ evaluate_impersonation_guard,
22
+ policy_methods,
23
+ process_request,
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
+
29
+
30
+ def _private_json_response(*args: Any, **kwargs: Any) -> Any:
31
+ from django.http import JsonResponse
32
+
33
+ response = JsonResponse(*args, **kwargs)
34
+ # These routes authenticate with custom headers, so caches cannot infer that
35
+ # customer search/detail responses require authorization.
36
+ response["Cache-Control"] = "private, no-store"
37
+ return response
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DjangoAdapterOptions:
42
+ timestamp_tolerance: Optional[int] = None
43
+ max_body_size: Optional[int] = None
44
+ trailing_slash: bool = False
45
+
46
+ def __post_init__(self) -> None:
47
+ validate_timestamp_tolerance(self.timestamp_tolerance)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class DjangoImpersonationGuardOptions:
52
+ sdk: Any
53
+ get_impersonation_context: Callable[[Any], Any]
54
+ on_blocked: Optional[Callable[[Any, ImpersonationContext], None]] = None
55
+ blocked_response: Mapping[str, Any] = field(default_factory=dict)
56
+ show_warnings: bool = False
57
+ enforce_liveness: bool = True
58
+ liveness_cache_ttl_ms: int = 5_000
59
+ on_liveness_unavailable: str = "deny"
60
+ is_impersonation_allowed: Optional[Callable[[Any, ImpersonationContext], bool]] = None
61
+ bridge_path: Optional[str] = None
62
+
63
+
64
+ def django_urlpatterns(sdk: Any, options: Optional[DjangoAdapterOptions] = None) -> list[Any]:
65
+ from django.urls import re_path
66
+
67
+ adapter_options = options or DjangoAdapterOptions()
68
+ routes = sdk.get_routes()
69
+ patterns: list[Any] = []
70
+
71
+ for route in routes:
72
+ view = django_view(sdk, routes, route, adapter_options)
73
+ patterns.append(
74
+ re_path(
75
+ django_route_pattern(route.path, trailing_slash=adapter_options.trailing_slash),
76
+ view,
77
+ name=_route_name(route),
78
+ )
79
+ )
80
+
81
+ return patterns
82
+
83
+
84
+ def django_adapter(sdk: Any, options: Optional[DjangoAdapterOptions] = None) -> list[Any]:
85
+ return django_urlpatterns(sdk, options)
86
+
87
+
88
+ def async_django_urlpatterns(sdk: Any, options: Optional[DjangoAdapterOptions] = None) -> list[Any]:
89
+ from django.urls import re_path
90
+
91
+ adapter_options = options or DjangoAdapterOptions()
92
+ routes = sdk.get_routes()
93
+ patterns: list[Any] = []
94
+
95
+ for route in routes:
96
+ view = async_django_view(sdk, routes, route, adapter_options)
97
+ patterns.append(
98
+ re_path(
99
+ django_route_pattern(route.path, trailing_slash=adapter_options.trailing_slash),
100
+ view,
101
+ name=_route_name(route),
102
+ )
103
+ )
104
+
105
+ return patterns
106
+
107
+
108
+ def django_view(
109
+ sdk: Any,
110
+ routes: list[SDKRoute],
111
+ route: SDKRoute,
112
+ options: DjangoAdapterOptions,
113
+ ) -> Callable[[Any], Any]:
114
+ from django.views.decorators.csrf import csrf_exempt
115
+
116
+ body_limit = _body_limit(options)
117
+
118
+ @csrf_exempt
119
+ def view(request: Any, *args: Any, **kwargs: Any) -> Any:
120
+ try:
121
+ path = route_relative_path(_wire_path(request), route.path)
122
+ if path is None:
123
+ return _private_json_response(create_error_response("Not found", "NOT_FOUND"), status=404)
124
+ adapter_request = AdapterRequest(
125
+ method=request.method,
126
+ path=path,
127
+ query=request.META.get("QUERY_STRING", ""),
128
+ body=_raw_body(request, body_limit),
129
+ headers=_headers_from_request(request),
130
+ )
131
+ response = process_request(
132
+ sdk,
133
+ routes,
134
+ adapter_request,
135
+ ProcessRequestOptions(
136
+ timestamp_tolerance=options.timestamp_tolerance,
137
+ max_body_size=options.max_body_size
138
+ if options.max_body_size is not None
139
+ else ProcessRequestOptions.max_body_size,
140
+ ),
141
+ )
142
+ status_code = 200 if response.get("success") else get_error_status_code(response.get("errorCode"))
143
+ return _private_json_response(response, status=status_code)
144
+ except _BodyReadError as error:
145
+ return _private_json_response({"success": False, "errorCode": error.code, "error": str(error)}, status=error.status)
146
+ except Exception:
147
+ return _private_json_response(
148
+ {
149
+ "success": False,
150
+ "errorCode": "INTERNAL_ERROR",
151
+ "error": "An unexpected error occurred",
152
+ },
153
+ status=500,
154
+ )
155
+
156
+ return view
157
+
158
+
159
+ def async_django_view(
160
+ sdk: Any,
161
+ routes: list[SDKRoute],
162
+ route: SDKRoute,
163
+ options: DjangoAdapterOptions,
164
+ ) -> Callable[[Any], Any]:
165
+ from asgiref.sync import sync_to_async
166
+ from django.views.decorators.csrf import csrf_exempt
167
+
168
+ body_limit = _body_limit(options)
169
+
170
+ @csrf_exempt
171
+ async def view(request: Any, *args: Any, **kwargs: Any) -> Any:
172
+ try:
173
+ path = route_relative_path(_wire_path(request), route.path)
174
+ if path is None:
175
+ return _private_json_response(create_error_response("Not found", "NOT_FOUND"), status=404)
176
+ adapter_request = AdapterRequest(
177
+ method=request.method,
178
+ path=path,
179
+ query=request.META.get("QUERY_STRING", ""),
180
+ body=await sync_to_async(_raw_body, thread_sensitive=True)(request, body_limit),
181
+ headers=_headers_from_request(request),
182
+ )
183
+ response = await async_process_request(
184
+ sdk,
185
+ routes,
186
+ adapter_request,
187
+ ProcessRequestOptions(
188
+ timestamp_tolerance=options.timestamp_tolerance,
189
+ max_body_size=options.max_body_size
190
+ if options.max_body_size is not None
191
+ else ProcessRequestOptions.max_body_size,
192
+ ),
193
+ )
194
+ status_code = 200 if response.get("success") else get_error_status_code(response.get("errorCode"))
195
+ return _private_json_response(response, status=status_code)
196
+ except _BodyReadError as error:
197
+ return _private_json_response({"success": False, "errorCode": error.code, "error": str(error)}, status=error.status)
198
+ except Exception:
199
+ return _private_json_response(
200
+ {
201
+ "success": False,
202
+ "errorCode": "INTERNAL_ERROR",
203
+ "error": "An unexpected error occurred",
204
+ },
205
+ status=500,
206
+ )
207
+
208
+ return view
209
+
210
+
211
+ def django_route_pattern(path: str, trailing_slash: bool = False) -> str:
212
+ parts = [part for part in path.split("/") if part]
213
+ regex_parts: list[str] = []
214
+ for part in parts:
215
+ if part.startswith(":"):
216
+ name = part[1:]
217
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
218
+ raise ValueError(f"Invalid route parameter name: {name}")
219
+ regex_parts.append(f"(?P<{name}>[^/]+)")
220
+ else:
221
+ regex_parts.append(re.escape(part))
222
+ suffix = "/?$" if trailing_slash else "$"
223
+ return "^" + "/".join(regex_parts) + suffix
224
+
225
+
226
+ def create_impersonation_guard(
227
+ sdk: Any,
228
+ get_impersonation_context: Callable[[Any], Any],
229
+ on_blocked: Optional[Callable[[Any, ImpersonationContext], None]] = None,
230
+ blocked_response: Optional[Mapping[str, Any]] = None,
231
+ show_warnings: bool = False,
232
+ enforce_liveness: bool = True,
233
+ liveness_cache_ttl_ms: int = 5_000,
234
+ on_liveness_unavailable: str = "deny",
235
+ is_impersonation_allowed: Optional[Callable[[Any, ImpersonationContext], bool]] = None,
236
+ bridge_path: Optional[str] = None,
237
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
238
+ """Django middleware factory (also usable as a view decorator).
239
+
240
+ Works in sync and async stacks. ``get_impersonation_context`` returns ``None``
241
+ for ordinary traffic, or a dict / ``ImpersonationContext``; in an async stack
242
+ it may be ``async``. Any other value fails closed with a 500.
243
+
244
+ Policy patterns match the full client-visible path (``request.path``,
245
+ including ``SCRIPT_NAME``). Deny rules also match ``path_info`` and the path
246
+ with an ``i18n_patterns`` language prefix removed. ``bridge_path`` enables the
247
+ read-scope allowance for a mounted browser-session bridge (off by default).
248
+ """
249
+ options = DjangoImpersonationGuardOptions(
250
+ sdk=sdk,
251
+ get_impersonation_context=get_impersonation_context,
252
+ on_blocked=on_blocked,
253
+ blocked_response=blocked_response or {},
254
+ show_warnings=show_warnings,
255
+ enforce_liveness=enforce_liveness,
256
+ liveness_cache_ttl_ms=liveness_cache_ttl_ms,
257
+ on_liveness_unavailable=on_liveness_unavailable,
258
+ is_impersonation_allowed=is_impersonation_allowed,
259
+ bridge_path=bridge_path,
260
+ )
261
+ liveness = (
262
+ SessionLivenessChecker(sdk, liveness_cache_ttl_ms, on_liveness_unavailable)
263
+ if enforce_liveness
264
+ else None
265
+ )
266
+
267
+ def decide(request: Any, raw_context: Any) -> Optional[Any]:
268
+ """Return a blocking JsonResponse, or None to continue."""
269
+
270
+ try:
271
+ context = coerce_impersonation_context(raw_context)
272
+ except InvalidImpersonationContext:
273
+ return _private_json_response(
274
+ {
275
+ "success": False,
276
+ "error": "Invalid impersonation context",
277
+ "errorCode": "INVALID_IMPERSONATION_CONTEXT",
278
+ },
279
+ status=500,
280
+ )
281
+ # Non-impersonation traffic passes through without policy or liveness lookups.
282
+ if not context or not context.is_impersonation:
283
+ return None
284
+ session_live = None
285
+ liveness_unavailable = False
286
+ if liveness and isinstance(context.session_id, str) and context.session_id:
287
+ session_live, liveness_unavailable = liveness.check(context.session_id)
288
+ path, aliases = _guard_paths(request)
289
+ decision = evaluate_impersonation_guard(
290
+ request.method,
291
+ path,
292
+ context,
293
+ options.sdk.get_scope_config(),
294
+ session_live=session_live,
295
+ liveness_unavailable=liveness_unavailable,
296
+ is_impersonation_allowed=(
297
+ (lambda value: options.is_impersonation_allowed(request, value))
298
+ if options.is_impersonation_allowed
299
+ else None
300
+ ),
301
+ bridge_path=options.bridge_path,
302
+ raw_path=_raw_request_path(request),
303
+ alias_paths=aliases,
304
+ methods=policy_methods(request.method, request.headers, request.META.get("QUERY_STRING", "")),
305
+ )
306
+ if decision.allowed:
307
+ return None
308
+
309
+ # Notify only for actual blocks (scope/endpoint/liveness), not for
310
+ # temporary policy unavailability (503) — matching the Node guard.
311
+ notify_blocked = decision.status_code == 403 or (decision.body or {}).get(
312
+ "errorCode"
313
+ ) == "IMPERSONATION_SESSION_ENDED"
314
+ if options.on_blocked and notify_blocked:
315
+ try:
316
+ options.on_blocked(request, context)
317
+ except Exception:
318
+ pass # Audit callbacks cannot change the decision.
319
+ if options.show_warnings:
320
+ warnings.warn(
321
+ f"[Devora] Blocked {request.method} {path} (session: {context.session_id})",
322
+ RuntimeWarning,
323
+ stacklevel=2,
324
+ )
325
+
326
+ status_code, body = _guard_response(decision.status_code, decision.body, options.blocked_response)
327
+ return _private_json_response(body, status=status_code)
328
+
329
+ def guard_failed() -> Any:
330
+
331
+ return _private_json_response(
332
+ {"success": False, "error": "Impersonation guard failed", "errorCode": "INTERNAL_ERROR"},
333
+ status=500,
334
+ )
335
+
336
+ def middleware(get_response: Callable[..., Any]) -> Callable[..., Any]:
337
+ if iscoroutinefunction(get_response):
338
+
339
+ async def async_wrapped(request: Any, *args: Any, **kwargs: Any) -> Any:
340
+ from asgiref.sync import sync_to_async
341
+
342
+ try:
343
+ raw_context = options.get_impersonation_context(request)
344
+ if inspect.isawaitable(raw_context):
345
+ raw_context = await raw_context
346
+ blocked = await sync_to_async(decide, thread_sensitive=False)(request, raw_context)
347
+ except Exception:
348
+ return guard_failed()
349
+ return blocked if blocked is not None else await get_response(request, *args, **kwargs)
350
+
351
+ markcoroutinefunction(async_wrapped)
352
+ return async_wrapped
353
+
354
+ def wrapped(request: Any, *args: Any, **kwargs: Any) -> Any:
355
+ try:
356
+ blocked = decide(request, options.get_impersonation_context(request))
357
+ except Exception:
358
+ return guard_failed()
359
+ return blocked if blocked is not None else get_response(request, *args, **kwargs)
360
+
361
+ return wrapped
362
+
363
+ middleware.sync_capable = True # type: ignore[attr-defined]
364
+ middleware.async_capable = True # type: ignore[attr-defined]
365
+ return middleware
366
+
367
+
368
+ def _guard_paths(request: Any) -> tuple[str, list[str]]:
369
+ """The full client-visible path, plus deny-only views of the same request."""
370
+ path = request.path or request.path_info or "/"
371
+ aliases: list[str] = []
372
+ path_info = request.path_info or "/"
373
+ if path_info != path:
374
+ aliases.append(path_info)
375
+ stripped = _strip_language_prefix(path_info)
376
+ if stripped is not None:
377
+ aliases.append(stripped)
378
+ script_name = path[: len(path) - len(path_info)] if path.endswith(path_info) else ""
379
+ if script_name:
380
+ aliases.append(script_name + stripped)
381
+ return path, aliases
382
+
383
+
384
+ def _strip_language_prefix(path_info: str) -> Optional[str]:
385
+ """``path_info`` without an ``i18n_patterns`` language prefix, when it has one."""
386
+ try:
387
+ from django.conf import settings
388
+ from django.utils.translation import get_language_from_path
389
+
390
+ if not getattr(settings, "USE_I18N", False) or not get_language_from_path(path_info):
391
+ return None
392
+ except Exception:
393
+ return None
394
+ # get_language_from_path only matches a supported language as the first segment.
395
+ rest = path_info.split("/", 2)
396
+ return "/" + (rest[2] if len(rest) > 2 else "")
397
+
398
+
399
+ def _route_name(route: SDKRoute) -> str:
400
+ name = route.path.strip("/").replace("/", "_").replace(":", "")
401
+ return f"devora_{route.method.lower()}_{name or 'root'}"
402
+
403
+
404
+ def _raw_request_path(request: Any) -> Optional[str]:
405
+ """The still-percent-encoded path, from the ASGI scope or a server-specific
406
+ WSGI environ key.
407
+
408
+ Devora signs path params with ``encodeURIComponent``; Django's decoded
409
+ ``request.path``/``path_info`` can no longer match that signature for values
410
+ containing reserved characters (``@``, ``+``, space, ...).
411
+ """
412
+ scope = getattr(request, "scope", None)
413
+ if isinstance(scope, Mapping):
414
+ raw = scope.get("raw_path")
415
+ if isinstance(raw, (bytes, bytearray)):
416
+ try:
417
+ return bytes(raw).decode("ascii")
418
+ except UnicodeDecodeError:
419
+ return bytes(raw).decode("latin-1")
420
+ environ = getattr(request, "environ", None)
421
+ if isinstance(environ, Mapping):
422
+ raw_uri = environ.get("RAW_URI") or environ.get("REQUEST_URI")
423
+ if raw_uri:
424
+ return raw_uri.split("?", 1)[0]
425
+ return None
426
+
427
+
428
+ def _wire_path(request: Any) -> str:
429
+ """The request path as sent, still percent-encoded.
430
+
431
+ ASGI ``raw_path`` or WSGI ``RAW_URI``/``REQUEST_URI`` when the server provides
432
+ them. Otherwise (for example ``manage.py runserver``) the decoded
433
+ ``PATH_INFO`` bytes are strict re-encoded, which reproduces exactly what
434
+ Devora's strict signer sent and fails closed for anything else.
435
+ """
436
+ raw = _raw_request_path(request)
437
+ if raw is not None:
438
+ return raw
439
+ # request.path is Django's own consistently decoded full path, whatever the
440
+ # server's PATH_INFO encoding quirks.
441
+ return "/".join(strict_encode(part) if part else "" for part in (request.path or "/").split("/"))
442
+
443
+
444
+ def _headers_from_request(request: Any) -> Any:
445
+ # ASGI retains duplicate fields. Django's normalized header map may have
446
+ # joined or discarded them, so feed the original pairs to the verifier.
447
+ scope = getattr(request, "scope", None)
448
+ if isinstance(scope, dict) and "headers" in scope:
449
+ return list(scope["headers"])
450
+ return {key.lower(): value for key, value in request.headers.items()}
451
+
452
+
453
+ class _BodyReadError(ValueError):
454
+ def __init__(self, message: str, status: int = 413) -> None:
455
+ super().__init__(message)
456
+ self.status = status
457
+ self.code = "BODY_TOO_LARGE" if status == 413 else "INVALID_BODY"
458
+
459
+
460
+ def _body_limit(options: DjangoAdapterOptions) -> int:
461
+ limit = options.max_body_size if options.max_body_size is not None else ProcessRequestOptions.max_body_size
462
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
463
+ raise ValueError("Invalid body size limit")
464
+ return limit
465
+
466
+
467
+ def _raw_body(request: Any, max_bytes: int = ProcessRequestOptions.max_body_size) -> bytes:
468
+ """The exact request body bytes, bounded before allocation."""
469
+ declared = request.META.get("CONTENT_LENGTH", "")
470
+ if declared:
471
+ if not str(declared).isascii() or not str(declared).isdigit():
472
+ raise _BodyReadError("Invalid Content-Length", 400)
473
+ if int(declared) > max_bytes:
474
+ raise _BodyReadError("Request body too large")
475
+ # HttpRequest.read() bounds SDK allocation without eagerly filling .body.
476
+ # ASGI servers and any earlier middleware still need their own ingress cap.
477
+ if callable(getattr(request, "read", None)):
478
+ raw = bytearray()
479
+ while len(raw) <= max_bytes:
480
+ part = request.read(min(65536, max_bytes + 1 - len(raw)))
481
+ if not part:
482
+ break
483
+ raw.extend(part)
484
+ if len(raw) > max_bytes:
485
+ raise _BodyReadError("Request body too large")
486
+ return bytes(raw)
487
+ raw = request.body
488
+ if len(raw) > max_bytes:
489
+ raise _BodyReadError("Request body too large")
490
+ return bytes(raw)
491
+
492
+
493
+ def _body_from_request(request: Any, max_bytes: int) -> Any:
494
+ """Parsed JSON body for the (unsigned) browser-session bridge."""
495
+ raw = _raw_body(request, max_bytes)
496
+ if not raw:
497
+ return None
498
+ return json.loads(raw.decode("utf-8"))
499
+
500
+
501
+ def _guard_response(
502
+ status_code: int,
503
+ body: Optional[dict[str, object]],
504
+ blocked_response: Mapping[str, Any],
505
+ ) -> tuple[int, dict[str, object]]:
506
+ response_body = dict(body or {})
507
+ if status_code == 403:
508
+ status_code = int(blocked_response.get("status", status_code))
509
+ if response_body.get("errorCode") == "IMPERSONATION_SCOPE_VIOLATION":
510
+ response_body["error"] = blocked_response.get(
511
+ "message", response_body.get("error", "This action is blocked during impersonation")
512
+ )
513
+ response_body["errorCode"] = blocked_response.get(
514
+ "errorCode", response_body.get("errorCode", "IMPERSONATION_SCOPE_VIOLATION")
515
+ )
516
+ return status_code, response_body
517
+
518
+
519
+ def browser_session_view(
520
+ sdk: Any,
521
+ get_impersonation_context: Callable[[Any], Any],
522
+ allowed_origins: Optional[list[str]] = None,
523
+ ) -> Callable[[Any], Any]:
524
+ """Django view for ``POST /api/devora/browser-session`` (wrap with your auth decorator)."""
525
+ from devora_sdk import resolve_browser_session
526
+ from django.views.decorators.csrf import csrf_exempt
527
+
528
+ # Exempt from Django's CSRF token check (the browser SDK cannot send one):
529
+ # the bridge authorizes by the session cookie AND an Origin that must be in
530
+ # ``allowed_origins``; a missing or foreign Origin never gets a resume code.
531
+ @csrf_exempt
532
+ def view(request: Any) -> Any:
533
+
534
+ if request.method != "POST":
535
+ return _private_json_response({"error": "Method not allowed"}, status=405)
536
+ try:
537
+ body = _body_from_request(request, 4096)
538
+ except _BodyReadError as error:
539
+ return _private_json_response({"success": False, "errorCode": error.code, "error": str(error)}, status=error.status)
540
+ except Exception:
541
+ body = {}
542
+ try:
543
+ context = coerce_impersonation_context(get_impersonation_context(request))
544
+ except InvalidImpersonationContext:
545
+ return _private_json_response(
546
+ {"status": "blocked", "reason": "session_invalid"}, status=500
547
+ )
548
+ status, payload = resolve_browser_session(
549
+ sdk,
550
+ context,
551
+ body.get("tabRef") if isinstance(body, dict) else None,
552
+ request.headers.get("Origin"),
553
+ allowed_origins,
554
+ )
555
+ response = _private_json_response(payload, status=status)
556
+ response["Cache-Control"] = "private, no-store"
557
+ return response
558
+
559
+ return view
@@ -0,0 +1 @@
1
+