toolframe-web 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.
Files changed (43) hide show
  1. toolframe_web/__init__.py +5 -0
  2. toolframe_web/asset_delivery.py +51 -0
  3. toolframe_web/assets.py +205 -0
  4. toolframe_web/authoring.py +29 -0
  5. toolframe_web/cli.py +29 -0
  6. toolframe_web/plugin.py +508 -0
  7. toolframe_web/policy.py +31 -0
  8. toolframe_web/prepare.py +447 -0
  9. toolframe_web/render.py +79 -0
  10. toolframe_web/request_body.py +95 -0
  11. toolframe_web/templates/notes/.gitignore +7 -0
  12. toolframe_web/templates/notes/LICENSE +21 -0
  13. toolframe_web/templates/notes/README.md +63 -0
  14. toolframe_web/templates/notes/pyproject.toml +25 -0
  15. toolframe_web/templates/notes/src/toolframe_web_notes/__init__.py +1 -0
  16. toolframe_web/templates/notes/src/toolframe_web_notes/contracts.py +50 -0
  17. toolframe_web/templates/notes/src/toolframe_web_notes/plugin.py +76 -0
  18. toolframe_web/templates/notes/src/toolframe_web_notes/service.py +81 -0
  19. toolframe_web/templates/notes/src/toolframe_web_notes/web/__init__.py +0 -0
  20. toolframe_web/templates/notes/src/toolframe_web_notes/web/app/__init__.py +16 -0
  21. toolframe_web/templates/notes/src/toolframe_web_notes/web/app/index.js +3 -0
  22. toolframe_web/templates/notes/src/toolframe_web_notes/web/app/styles/input.css +1 -0
  23. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/__init__.py +0 -0
  24. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/__init__.py +6 -0
  25. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/api/__init__.py +17 -0
  26. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/index.js +1 -0
  27. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/model/__init__.py +26 -0
  28. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/model/count.js +6 -0
  29. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/__init__.py +0 -0
  30. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/index.js +47 -0
  31. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/routes.py +94 -0
  32. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/templates/index.html +23 -0
  33. toolframe_web/templates/notes/src/toolframe_web_notes/web/pages/notes/ui/templates/notes.html +29 -0
  34. toolframe_web/templates/notes/tests/test_contracts.py +30 -0
  35. toolframe_web/templates/notes/web-assets.toml +54 -0
  36. toolframe_web-0.1.0.dist-info/METADATA +83 -0
  37. toolframe_web-0.1.0.dist-info/RECORD +43 -0
  38. toolframe_web-0.1.0.dist-info/WHEEL +4 -0
  39. toolframe_web-0.1.0.dist-info/entry_points.txt +5 -0
  40. toolframe_web-0.1.0.dist-info/extra_metadata/toolframe-authoring.json +29 -0
  41. toolframe_web-0.1.0.dist-info/licenses/LICENSE +202 -0
  42. toolframe_web-0.1.0.dist-info/licenses/NOTICE +7 -0
  43. toolframe_web-0.1.0.dist-info/licenses/src/toolframe_web/templates/notes/LICENSE +21 -0
@@ -0,0 +1,508 @@
1
+ """HTTP adapter for prepared SDK Web applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib.resources
7
+ import inspect
8
+ import secrets
9
+ import shutil
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from types import MappingProxyType, SimpleNamespace
14
+ from urllib.parse import parse_qs
15
+
16
+ import anyio
17
+ from jinja2 import DictLoader, Environment, PackageLoader, StrictUndefined
18
+ from starlette.applications import Starlette
19
+ from starlette.exceptions import HTTPException
20
+ from starlette.requests import Request
21
+ from starlette.responses import JSONResponse, Response
22
+ from starlette.routing import Route
23
+ from toolframe_sdk import (
24
+ AuthorizationSpec,
25
+ Plugin,
26
+ PluginDefinition,
27
+ PluginError,
28
+ WebApp,
29
+ WebContext,
30
+ WebRequest,
31
+ WebResponse,
32
+ load_object,
33
+ )
34
+ from toolframe_sdk.http import HttpServer
35
+ from toolframe_sdk.web import WebAppCatalog
36
+
37
+ from .asset_delivery import deliver_asset
38
+ from .assets import AssetSet
39
+ from .policy import application_policy
40
+ from .request_body import FORM_TYPES, RequestBody
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Application:
45
+ resource: object
46
+ app: WebApp
47
+ assets: AssetSet
48
+ catalog: WebAppCatalog
49
+ templates: Environment
50
+
51
+
52
+ ERROR_STATUS = {
53
+ "authentication_required": 401,
54
+ "session_invalid": 401,
55
+ "forbidden": 403,
56
+ "csrf_failed": 403,
57
+ "web_asset_missing": 404,
58
+ "web_revision_stale": 409,
59
+ "stale_catalog": 409,
60
+ "web_context_closed": 409,
61
+ "invalid_request": 422,
62
+ "request_too_large": 413,
63
+ "rate_limited": 429,
64
+ "presentation_unavailable": 503,
65
+ "auth_unavailable": 503,
66
+ "auth_transition": 503,
67
+ "plugin_reloading": 503,
68
+ }
69
+
70
+
71
+ class WebProvider(Plugin):
72
+ def __init__(self, context):
73
+ super().__init__(context)
74
+ self.server = None
75
+ self.snapshot = (None, {})
76
+ self.staged = {}
77
+ self.limiter = None
78
+ self.handlers = set()
79
+ self.closing = False
80
+ self.cache = Path(context.state_directory) if context.state_directory else None
81
+
82
+ async def activate(self):
83
+ self.limiter = anyio.CapacityLimiter(8)
84
+ self.server = HttpServer(
85
+ Starlette(
86
+ routes=[
87
+ Route(
88
+ "/{path:path}",
89
+ self.handle,
90
+ methods=[
91
+ "GET",
92
+ "HEAD",
93
+ "POST",
94
+ "PUT",
95
+ "PATCH",
96
+ "DELETE",
97
+ "OPTIONS",
98
+ ],
99
+ )
100
+ ]
101
+ )
102
+ )
103
+ await self.server.start()
104
+
105
+ async def health(self):
106
+ return {
107
+ "runtime": "ready",
108
+ "business": "not_provided",
109
+ "http_url": self.server.url if self.server else None,
110
+ "capabilities": ["web-app-v1", "gateway-endpoints-v1"],
111
+ "epoch": self.snapshot[0],
112
+ "apps": sorted(self.snapshot[1]),
113
+ }
114
+
115
+ async def dispose(self):
116
+ self.closing = True
117
+ if self.server:
118
+ await self.server.close()
119
+ await super().dispose()
120
+
121
+ async def prepare(self, resources, epoch):
122
+ apps = {}
123
+ total = 0
124
+ for resource in resources:
125
+ if resource.resource.kind != "web.app" or resource.key in apps:
126
+ raise PluginError(
127
+ "unsupported_resource",
128
+ "Web provider requires distinct web.app resources",
129
+ )
130
+ data = resource.resource.data
131
+ value = load_object(data["entrypoint"])
132
+ app = value if isinstance(value, WebApp) else value()
133
+ if inspect.isawaitable(app):
134
+ app = await app
135
+ if not isinstance(app, WebApp):
136
+ raise PluginError(
137
+ "web_contract_unsupported", "Web entrypoint must return SDK WebApp"
138
+ )
139
+ assets = AssetSet.load(data["package"], data["assets"])
140
+ if app.spa_fallback and not any(
141
+ item.path == app.spa_fallback and item.media_type == "text/html"
142
+ for item in assets.manifest.files
143
+ ):
144
+ raise PluginError(
145
+ "web_contract_unsupported",
146
+ "SPA fallback must name a declared HTML asset",
147
+ )
148
+ total += sum(item.size for item in assets.manifest.files)
149
+ if total > 128 * 1024 * 1024:
150
+ raise PluginError(
151
+ "web_assets_too_large", "Web cohort assets exceed 128 MiB"
152
+ )
153
+ catalog = WebAppCatalog(
154
+ routes=tuple(route.spec() for route in app.routes),
155
+ asset_digest=assets.manifest.digest,
156
+ asset_paths=tuple(item.path for item in assets.manifest.files),
157
+ asset_visibility=assets.manifest.document.visibility,
158
+ csp=dict(app.csp),
159
+ spa_fallback=app.spa_fallback,
160
+ )
161
+ template_root = importlib.resources.files(data["package"]).joinpath(
162
+ app.templates
163
+ )
164
+ templates = Environment(
165
+ loader=PackageLoader(data["package"], app.templates)
166
+ if template_root.is_dir()
167
+ else DictLoader({}),
168
+ autoescape=True,
169
+ undefined=StrictUndefined,
170
+ auto_reload=False,
171
+ enable_async=False,
172
+ )
173
+ # Compile templates before publication, so syntax errors preserve the old app.
174
+ for name in templates.list_templates():
175
+ templates.get_template(name)
176
+ apps[resource.key] = Application(resource, app, assets, catalog, templates)
177
+ self.staged[epoch] = apps
178
+ return {
179
+ "epoch": epoch,
180
+ "web_apps": {
181
+ key: app.catalog.model_dump(mode="json") for key, app in apps.items()
182
+ },
183
+ }
184
+
185
+ async def commit(self, epoch):
186
+ if epoch not in self.staged:
187
+ raise PluginError("unknown_epoch", "No prepared Web cohort")
188
+ apps = self.staged.pop(epoch)
189
+ if self.cache:
190
+ for app in apps.values():
191
+ await asyncio.to_thread(app.assets.persist, self.cache)
192
+ # Keep the selected and immediately preceding set for each application.
193
+ import json
194
+
195
+ self.cache.mkdir(parents=True, exist_ok=True)
196
+ index = self.cache / "selected.json"
197
+ prior = json.loads(index.read_text()) if index.exists() else {}
198
+ old = prior.get("current", {})
199
+ selected = {key: app.catalog.asset_digest for key, app in apps.items()}
200
+ previous = {
201
+ key: value
202
+ for key, value in prior.get("previous", {}).items()
203
+ if key in selected and value["expires"] > time.time()
204
+ }
205
+ for key, checksum in old.items():
206
+ if key in selected and selected[key] != checksum:
207
+ previous[key] = {"digest": checksum, "expires": time.time() + 600}
208
+ keep = set(selected.values()) | {
209
+ value["digest"] for value in previous.values()
210
+ }
211
+ for item in self.cache.iterdir():
212
+ if item.is_dir() and len(item.name) == 64 and item.name not in keep:
213
+ await asyncio.to_thread(shutil.rmtree, item)
214
+ temporary = index.with_suffix(".tmp")
215
+ temporary.write_text(
216
+ json.dumps({"current": selected, "previous": previous})
217
+ )
218
+ temporary.replace(index)
219
+ self.snapshot = (epoch, apps)
220
+ return {"epoch": epoch}
221
+
222
+ async def abort(self, epoch):
223
+ self.staged.pop(epoch, None)
224
+ return {"epoch": epoch}
225
+
226
+ async def run_sync(self, handler, request, context):
227
+ # A timed-out thread keeps its slot until it actually exits. New requests
228
+ # cannot build an unbounded queue behind abandoned synchronous handlers.
229
+ token = object()
230
+ try:
231
+ self.limiter.acquire_on_behalf_of_nowait(token)
232
+ except anyio.WouldBlock:
233
+ raise PluginError(
234
+ "rate_limited", "Synchronous Web handlers are busy"
235
+ ) from None
236
+
237
+ async def execute():
238
+ try:
239
+ return await anyio.to_thread.run_sync(handler, request, context)
240
+ finally:
241
+ self.limiter.release_on_behalf_of(token)
242
+
243
+ task = asyncio.create_task(execute())
244
+ self.handlers.add(task)
245
+
246
+ def completed(future):
247
+ self.handlers.discard(future)
248
+ if not future.cancelled():
249
+ future.exception()
250
+
251
+ task.add_done_callback(completed)
252
+ return await asyncio.shield(task)
253
+
254
+ async def handle(self, request: Request):
255
+ epoch, apps = self.snapshot
256
+ key = request.headers.get("x-toolframe-resource")
257
+ reference = request.headers.get("x-toolframe-context")
258
+ app = apps.get(key)
259
+ if not reference or not app or self.closing:
260
+ return JSONResponse(
261
+ {
262
+ "error": {
263
+ "code": "forbidden",
264
+ "message": "Open this app through the Toolframe public URL",
265
+ }
266
+ },
267
+ status_code=403,
268
+ )
269
+ request_id = request.headers.get(
270
+ "x-toolframe-request-id", secrets.token_hex(12)
271
+ )
272
+ context = None
273
+ body = None
274
+ try:
275
+ if request.scope["path"].startswith("/_tf/assets/"):
276
+ return await deliver_asset(self, request, app, key, epoch, reference)
277
+ matches = [
278
+ (route, route.spec().match(request.scope["path"]))
279
+ for route in app.app.routes
280
+ ]
281
+ matches = [
282
+ (route, params) for route, params in matches if params is not None
283
+ ]
284
+ if not matches:
285
+ # Fallback remains within the selected app and never handles asset misses.
286
+ if (
287
+ app.catalog.spa_fallback
288
+ and request.method in {"GET", "HEAD"}
289
+ and "text/html" in request.headers.get("accept", "")
290
+ ):
291
+ route = SimpleNamespace(
292
+ id="@spa",
293
+ spec=lambda: SimpleNamespace(allowed_methods={"GET", "HEAD"}),
294
+ handler=lambda request, ctx: WebResponse(
295
+ app.assets.content[app.catalog.spa_fallback],
296
+ headers={"Content-Type": "text/html; charset=utf-8"},
297
+ ),
298
+ )
299
+ matches = [(route, {})]
300
+ else:
301
+ return JSONResponse(
302
+ {
303
+ "error": {
304
+ "code": "web_route_missing",
305
+ "message": "Unknown app route",
306
+ }
307
+ },
308
+ status_code=404,
309
+ )
310
+ selected = next(
311
+ (
312
+ (route, params)
313
+ for route, params in matches
314
+ if request.method in route.spec().allowed_methods
315
+ ),
316
+ None,
317
+ )
318
+ if request.method == "OPTIONS":
319
+ selected = matches[0]
320
+ if selected is None:
321
+ return Response(
322
+ status_code=405,
323
+ headers={
324
+ "Allow": ", ".join(
325
+ sorted(
326
+ {
327
+ method
328
+ for route, _ in matches
329
+ for method in route.spec().allowed_methods
330
+ }
331
+ | {"OPTIONS"}
332
+ )
333
+ )
334
+ },
335
+ )
336
+ route, params = selected
337
+ settings = self.context.endpoints.get("web", {})
338
+ safe = request.method in {"GET", "HEAD", "OPTIONS"}
339
+ admission = await self.context.resource_admit(
340
+ key,
341
+ route.id,
342
+ epoch=epoch,
343
+ access_context=reference,
344
+ phase="complete" if safe else "authorize",
345
+ )
346
+ body = RequestBody(request, settings.get("max_body_bytes", 8 * 1024 * 1024))
347
+ await body.read()
348
+ if not safe:
349
+ csrf = request.headers.get("x-csrf-token", "")
350
+ revision = request.headers.get("x-web-revision", "")
351
+ if body.media_type in FORM_TYPES:
352
+ values = await body.form()
353
+ csrf = csrf or values.get("_tf_csrf", "")
354
+ revision = revision or values.get("_tf_revision", "")
355
+ admission = await self.context.resource_admit(
356
+ key,
357
+ route.id,
358
+ epoch=epoch,
359
+ access_context=reference,
360
+ csrf=csrf,
361
+ revision=revision,
362
+ )
363
+ if request.method == "OPTIONS":
364
+ return Response(
365
+ status_code=204,
366
+ headers={
367
+ "Allow": ", ".join(
368
+ sorted(
369
+ {
370
+ method
371
+ for item, _ in matches
372
+ for method in item.spec().allowed_methods
373
+ }
374
+ | {"OPTIONS"}
375
+ )
376
+ )
377
+ },
378
+ )
379
+ context = WebContext(
380
+ self.context, app.resource, epoch, reference, admission, app.catalog
381
+ )
382
+ context.nonce = secrets.token_urlsafe(24)
383
+ context._assets, context._templates = app.assets, app.templates
384
+ value = WebRequest(
385
+ request.method,
386
+ request.scope["path"],
387
+ MappingProxyType(
388
+ {
389
+ key: tuple(values)
390
+ for key, values in parse_qs(
391
+ request.scope.get("query_string", b"").decode("ascii"),
392
+ keep_blank_values=True,
393
+ ).items()
394
+ }
395
+ ),
396
+ MappingProxyType(params),
397
+ MappingProxyType(
398
+ {
399
+ key: value
400
+ for key, value in request.headers.items()
401
+ if key not in {"cookie", "authorization"}
402
+ and not key.startswith("x-toolframe-")
403
+ }
404
+ ),
405
+ body.content,
406
+ body.form,
407
+ )
408
+ async with asyncio.timeout(settings.get("request_timeout", 30)):
409
+ if inspect.iscoroutinefunction(route.handler):
410
+ result = await route.handler(value, context)
411
+ else:
412
+ result = await self.run_sync(route.handler, value, context)
413
+ if not isinstance(result, WebResponse):
414
+ raise TypeError("Web handler must return WebResponse")
415
+ response_body = (
416
+ result.body.encode() if isinstance(result.body, str) else result.body
417
+ )
418
+ if len(response_body) > 8 * 1024 * 1024:
419
+ raise ValueError("Web response exceeds 8 MiB")
420
+ headers = {
421
+ key: value
422
+ for key, value in result.headers.items()
423
+ if key.lower()
424
+ not in {
425
+ "content-security-policy",
426
+ "content-length",
427
+ "transfer-encoding",
428
+ "connection",
429
+ }
430
+ }
431
+ headers.update(
432
+ {
433
+ "Content-Security-Policy": application_policy(
434
+ app.catalog, nonce=context.nonce
435
+ ),
436
+ "Cache-Control": "private, no-store",
437
+ "Vary": "HX-Request, Accept",
438
+ "X-Web-Revision": context.revision,
439
+ "X-Content-Type-Options": "nosniff",
440
+ "Referrer-Policy": "same-origin",
441
+ }
442
+ )
443
+ return Response(
444
+ b"" if request.method == "HEAD" else response_body,
445
+ status_code=result.status,
446
+ headers=headers,
447
+ )
448
+ except PluginError as error:
449
+ return JSONResponse(
450
+ {
451
+ "error": {"code": error.code, "message": error.message},
452
+ "login_url": "/_toolframe/auth/login",
453
+ "request_id": request_id,
454
+ },
455
+ status_code=ERROR_STATUS.get(error.code, 400),
456
+ headers={"Cache-Control": "no-store"},
457
+ )
458
+ except TimeoutError:
459
+ return JSONResponse(
460
+ {
461
+ "error": {
462
+ "code": "web_timeout",
463
+ "message": "Web request timed out",
464
+ },
465
+ "request_id": request_id,
466
+ },
467
+ status_code=504,
468
+ )
469
+ except HTTPException:
470
+ return JSONResponse(
471
+ {
472
+ "error": {
473
+ "code": "invalid_request",
474
+ "message": "Invalid form encoding or field limits",
475
+ },
476
+ "request_id": request_id,
477
+ },
478
+ status_code=422,
479
+ )
480
+ except Exception as error: # noqa: BLE001 - never expose handler errors or filesystem paths over HTTP
481
+ self.context.log.error(
482
+ "Web request failed",
483
+ extra={
484
+ "error.type": type(error).__name__,
485
+ "toolframe.web.request_id": request_id,
486
+ },
487
+ )
488
+ return JSONResponse(
489
+ {
490
+ "error": {"code": "web_error", "message": "Web request failed"},
491
+ "request_id": request_id,
492
+ },
493
+ status_code=500,
494
+ )
495
+ finally:
496
+ if context:
497
+ context.close()
498
+ if body is not None:
499
+ await body.close()
500
+
501
+
502
+ plugin = PluginDefinition(
503
+ id="web",
504
+ kind="provider",
505
+ provider_kind="web",
506
+ factory="toolframe_web.plugin:WebProvider",
507
+ authorization=AuthorizationSpec(),
508
+ )
@@ -0,0 +1,31 @@
1
+ """Response CSP derived exclusively from the validated application catalog."""
2
+
3
+ RESTRICTED_ASSET_POLICY = "default-src 'none'; frame-ancestors 'none'; sandbox"
4
+
5
+
6
+ def application_policy(catalog, *, nonce=None):
7
+ policy = {
8
+ "default-src": ("'none'",),
9
+ "script-src": ("'self'",),
10
+ "style-src": ("'self'",),
11
+ "img-src": ("'self'", "data:"),
12
+ "font-src": ("'self'",),
13
+ "connect-src": ("'self'",),
14
+ "worker-src": ("'none'",),
15
+ "base-uri": ("'none'",),
16
+ "object-src": ("'none'",),
17
+ "form-action": ("'self'",),
18
+ "frame-ancestors": ("'none'",),
19
+ **dict(catalog.csp),
20
+ }
21
+ if nonce and "'none'" not in policy["script-src"]:
22
+ policy["script-src"] = (*policy["script-src"], f"'nonce-{nonce}'")
23
+ return "; ".join(key + " " + " ".join(values) for key, values in policy.items())
24
+
25
+
26
+ def asset_policy(catalog, item):
27
+ # Workers use their script response's CSP. A stable policy for every script
28
+ # avoids Sec-Fetch-Dest cache variants; HTML/SVG stay sandboxed.
29
+ if item.media_type == "text/javascript":
30
+ return application_policy(catalog)
31
+ return RESTRICTED_ASSET_POLICY