diff --git a/README.md b/README.md index 1ec38ba..1a1acd1 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,9 @@ secret. Set the callback URL to: **2. Set environment variables:** ```bash -# Required to sign session cookies (generate once and keep it stable): +# Required & persistent in production — generate once and keep it stable. +# Under PYPLET_REQUIRE_AUTH=1 the server refuses to boot when this is unset +# (a per-process random secret logs out every user on each restart): export PYPLET_COOKIE_SECRET=$(python -c "import secrets; print(secrets.token_hex(32))") # Google @@ -193,9 +195,8 @@ export OAUTH_MICROSOFT_TENANT=common # or your tenant ID ### Access control (ACL) -By default every authenticated user can see all apps. To restrict access, -create `apps/auth_rules.json` — a JSON array of -`["project/app regex", "email regex"]` pairs: +To restrict which apps each user can see, create `apps/auth_rules.json` — a +JSON array of `["project/app regex", "email regex"]` pairs: ```json [ @@ -211,6 +212,12 @@ If no rule matches, access is denied. Override the rules file path with `PYPLET_AUTH_RULES_FILE`. +**Deny-by-default (fail closed):** when authentication is enabled but the rules +file is **missing**, access is **denied** to every app — ship `auth_rules.json` +in your deploy artifact. (When auth is fully disabled — local dev with no +provider — a missing file still allows all apps, so an un-authenticated local +run works.) + ### Magic-link e-mail authentication As an alternative (or complement) to OAuth, users can sign in by entering their @@ -237,11 +244,39 @@ The ACL rules file applies to magic-link logins exactly the same way it does for OAuth: the user's e-mail address is matched against the `email_regex` column of each rule. +Because magic-link mints a session for **any** e-mail that can receive the +link, it is **refused at boot on the production profile** (`PYPLET_REQUIRE_AUTH=1`, +below) unless you opt in explicitly with `PYPLET_ALLOW_MAGICLINK=1`. + +### Production fail-closed startup (`PYPLET_REQUIRE_AUTH`) + +On any non-local deployment, set `PYPLET_REQUIRE_AUTH=1`. With it, the server +**refuses to boot** (exits non-zero with a logged error) rather than silently +serving anonymously when the auth config is misdelivered — specifically when +**no** auth method is configured, when `auth_rules.json` is **missing**, or +when magic-link is enabled **without** `PYPLET_ALLOW_MAGICLINK=1`. Without the +flag (the default), a deployment with no provider still starts but logs a loud +WARNING that every request is served anonymously. + +Three further production-profile guards ship with this posture. The server +**refuses to boot when `PYPLET_DEBUG=1` under `PYPLET_REQUIRE_AUTH=1`** — +Tornado debug mode enables autoreload and exposes traceback pages, so set +`PYPLET_DEBUG=0` in production. Behind a TLS-terminating reverse proxy, +`app.listen` trusts `X-Forwarded-For`/`X-Forwarded-Proto` (`xheaders`) and +WebSocket upgrades are origin-checked against the `PYPLET_URL` host +(same-origin when `PYPLET_URL` is unset). At login, OIDC `id_token`s are +verified against the provider JWKS (RS256 signature, issuer, audience and +expiry) before a session is established. + ### Configuration reference | Variable | Description | | --- | --- | | `PYPLET_COOKIE_SECRET` | Secret for signing session cookies | +| `PYPLET_SECURE_COOKIES` | Force `Secure` attribute on auth cookies: `1`/`0` | +| `PYPLET_REQUIRE_AUTH` | Fail-closed switch: `1` refuses boot, default `0` | +| `PYPLET_ALLOW_MAGICLINK` | Opt magic-link IN on require-auth, default `0` | +| `PYPLET_SESSION_TTL_DAYS` | Session cookie lifetime in days, default `1` | | **OAuth — Google** | | | `OAUTH_GOOGLE_CLIENT_ID` | Google OAuth2 client ID | | `OAUTH_GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | @@ -260,6 +295,10 @@ column of each rule. | **ACL** | | | `PYPLET_AUTH_RULES_FILE` | ACL rules path (default: `apps/auth_rules.json`) | +`PYPLET_COOKIE_SECRET` must be persistent and is required under +`PYPLET_REQUIRE_AUTH=1` (the server refuses to boot when unset); +`PYPLET_SECURE_COOKIES`, when unset, follows the `PYPLET_URL` scheme. + ## Advanced Features ### DOM Manipulation @@ -337,9 +376,10 @@ Available configuration options: - `--address` / `PYPLET_ADDR` - Server address (default: `127.0.0.1`) - `--port` / `PYPLET_PORT` - Server port (default: `8080`) - `--apps` / `PYPLET_APPS` - Apps directory (default: `apps`) -- `--debug` / `PYPLET_DEBUG` - Debug mode (default: `1`) +- `--debug` / `PYPLET_DEBUG` - Debug mode (default `1`; must be `0` in prod) - `--pyodide-url` / `PYPLET_PYODIDE` - Pyodide CDN URL - `--url` / `PYPLET_URL` - Custom URL override +- `PYPLET_WS_MAX_MESSAGE_MB` - Max WebSocket frame size MB (default: `40`) See the [Authentication](#authentication) section for OAuth-related variables. @@ -388,6 +428,21 @@ Since client code runs in PyScript (WebAssembly): ## Contributing +### Where development happens + +Pyplet lives in two places, and they are not interchangeable: + +- **GitLab `seglab/pyplet`** (CETIC forge, `git.cetic.be`) is the + **canonical** repository. Development lands there, on the default + branch `main`, through merge requests. +- **GitHub [`cetic/Pyplet`](https://github.com/cetic/Pyplet/)** is the + **publication mirror**. It is deliberately behind: nothing is developed + there, and it is refreshed from GitLab `main` by a maintainer when a + state is worth publishing. + +The flow is one-way: **GitLab `main` → GitHub**. A change pushed straight +to GitHub would be overwritten by the next publication. + Contributions are welcome! When contributing: 1. Maintain clean separation between client and server code diff --git a/docs/api/oauth-providers.md b/docs/api/oauth-providers.md new file mode 100644 index 0000000..cf04005 --- /dev/null +++ b/docs/api/oauth-providers.md @@ -0,0 +1,123 @@ +# OAuth providers and consent flows + +`pyplet.server.oauth` is a provider-agnostic OIDC engine. It owns discovery, +the CSRF state cookie, the code exchange, JWKS signature verification, the +signed session cookie and the fail-closed startup policy — and it branches on +no provider name and hardcodes no vendor endpoint. + +Everything vendor-specific lives in one of two places: + +- **`pyplet.server.oauth_providers`** — the presets Pyplet ships (Google, + Microsoft/Entra ID), registered into the engine at import. +- **Your application** — anything else, registered from your own module at + import time. + +## Registering a provider + +```python +from pyplet.server import oauth + +oauth.register_provider("corporate", { + "label": "Corporate SSO", # login-button text + "openid_config_url": "https://id.corp.example/.well-known/openid-configuration", + "client_id": lambda: os.environ.get("CORP_CLIENT_ID", ""), + "client_secret": lambda: os.environ.get("CORP_CLIENT_SECRET", ""), + "scopes": ["openid", "email", "profile"], + "auth_params": {"prompt": "select_account"}, # optional +}) +``` + +`openid_config_url`, `client_id` and `client_secret` are required; a spec +missing one raises `ValueError` **at registration**, so the traceback names the +app instead of surfacing as a broken login in production. + +Any value may be a zero-argument callable, resolved at use time. That is how a +spec reads configuration lazily rather than freezing an env var at import. + +A provider only appears on the login page once its `client_id` resolves to +something non-empty — registration is not configuration. Registering an +existing name **replaces** it, which is how an app overrides a shipped preset +(apps are loaded before the Tornado app is built, so an app-side registration +always wins). + +## Incremental consent + +An incremental-consent flow re-runs the authorization-code round-trip on top of +an existing login to obtain **extra scopes** — and, if it asks for offline +access, a refresh token — without touching the session. The user stays logged +in throughout. + +```python +async def _store_token(handler, user_info, tokens): + """Called once the callback has verified the id_token.""" + await save_refresh_token( + user_info["sub"], tokens.get("refresh_token"), tokens.get("scope", "") + ) + +oauth.register_consent_flow("files", { + "provider": "corporate", + "scopes": ["https://api.corp.example/auth/files"], + "auth_params": {"access_type": "offline", "prompt": "consent"}, + "on_complete": _store_token, +}) +``` + +Start it from a Tornado handler: + +```python +await oauth.start_consent(handler, "files", next_url="/apps/me/back-here") +``` + +`start_consent` stores `"flow": "files"` in the state cookie; `handle_callback` +reads it back and hands the **raw token response** to `on_complete` instead of +calling `set_session`. The engine does not interpret `refresh_token` or +`scope` — what the extra scopes are for is entirely the application's business. + +Two deliberate behaviours: + +- An exception from `on_complete` is logged and swallowed, then the browser is + redirected anyway. It is mid-redirect from the provider; a bookkeeping + failure must not strand it on an error page. +- A state cookie naming an **unregistered** flow is refused with a 400. Falling + through would turn a consent round-trip into an unrequested login. + +## Migrating off the Drive-specific API + +The engine previously carried a Google-Drive-shaped path: `register_drive_token_hook()`, +`start_drive_consent()`, and a `state["flow"] == "drive"` branch in the +callback, with `drive.file`/`access_type=offline` hardcoded. That was one +application's requirement living in the framework. It is replaced by the +generic pair above. + +| Removed | Replacement | +| --- | --- | +| `register_drive_token_hook(fn)` | `register_consent_flow(name, {...,"on_complete": fn})` | +| `start_drive_consent(handler, next_url)` | `start_consent(handler, name, next_url)` | +| hardcoded `state["flow"] == "drive"` | any registered flow name | +| hardcoded `drive.file` scope + `access_type=offline` | the flow's `scopes` / `auth_params` | + +The hook signature changes from `(sub, email, refresh_token, scopes)` to +`(handler, user_info, tokens)`. Adapt an existing hook in place: + +```python +oauth.register_consent_flow("drive", { + "provider": "google", + "scopes": ["https://www.googleapis.com/auth/drive.file"], + "auth_params": { + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "true", + }, + "on_complete": lambda handler, user_info, tokens: existing_hook( + user_info["sub"], + user_info["email"], + tokens.get("refresh_token"), + tokens.get("scope", ""), + ), +}) +``` + +The authorization request this produces is equivalent to the one +`start_drive_consent` used to build — same endpoint, same scopes, same +parameters and values. Only the order in which the query string encodes +them differs, which OAuth does not treat as significant. diff --git a/pyplet/client/__init__.py b/pyplet/client/__init__.py index 72e97cd..cc56bea 100644 --- a/pyplet/client/__init__.py +++ b/pyplet/client/__init__.py @@ -460,7 +460,21 @@ async def bootstrap_client(prefix, project_name, app_name, deps=()): for dep in deps: mip.install(dep) else: - import micropip + try: + import micropip + except ModuleNotFoundError: + # micropip ships with Pyodide, but a reload after the app has + # already been used can restore a partial/inconsistent Pyodide + # package set from PyScript's IndexedDB cache (@pyscript.fs + + # the pyodide package cache), leaving micropip unregistered so + # `import micropip` raises ModuleNotFoundError at boot. Recover + # exactly as the error message itself advises: pull the package + # in via the Pyodide JS API and retry. This self-heals a stale + # or dirty cache without forcing the user to clear IndexedDB. + import pyodide_js + + await pyodide_js.loadPackage("micropip") + import micropip await micropip.install(list(deps)) @@ -501,6 +515,15 @@ async def bootstrap_client(prefix, project_name, app_name, deps=()): ): await client_application.client_init() + # Drop the boot splash (server-rendered into #container) now that the app + # module has loaded and any client_init UI has mounted. Apps that replace + # #container's contents already removed it; this by-id removal also covers + # apps that append to #container, so the spinner never lingers. Gate on + # truthiness — getElementById yields a falsy JS null when already gone. + splash = js.document.getElementById("pyplet-boot-splash") + if splash: + splash.remove() + if ( client_application.__class__.websocket_client_loop is not ClientApplication.websocket_client_loop diff --git a/pyplet/server/_server.py b/pyplet/server/_server.py index e2e7aa1..6422473 100644 --- a/pyplet/server/_server.py +++ b/pyplet/server/_server.py @@ -11,6 +11,7 @@ import sys import textwrap import types +import urllib.parse from pathlib import Path from typing import Dict, Optional, Tuple @@ -130,6 +131,72 @@ def set_extra_headers(self, path: str) -> None: self.set_header("Cache-Control", "no-cache") +class AppStaticFileHandler(tornado.web.StaticFileHandler): + """Serve an app's static assets, confined to ``//static``. + + The route captures the app *project* and the file *tail* as two separate + groups (``/apps//static/``) and this handler roots itself at + that one app's ``static/`` directory per request. Tornado's + ``validate_absolute_path`` only guarantees a request cannot escape + ``self.root``; rooting the handler at the whole ``apps/`` tree (the old + behaviour) let a ``..`` in the tail climb out of ``static/`` into a sibling + app's server source (``*_server.py``) or the ACL file (``auth_rules.json``) + while staying under ``apps/`` — an unauthenticated path traversal. Rooting + per-app closes it: a ``..`` (raw or percent-encoded, which Tornado + url-decodes before matching) can no longer resolve to anything outside the + requested app's own ``static/`` dir (attempts get 403/404). Apps with no + ``static/`` dir simply 404 rather than crashing the server. + + Tornado's containment test is purely lexical, so ``validate_absolute_path`` + is overridden to re-check the symlink-resolved paths too — see there. + """ + + def initialize(self, apps_root: str) -> None: + # StaticFileHandler.initialize requires a ``path``; the real + # per-request root is (re)computed in get() once ``project`` is known. + super().initialize(path=apps_root) + self._apps_root = apps_root + + async def get( # noqa: A003 - Tornado handler hook + self, project: str, path: str, include_body: bool = True + ) -> None: + # Confine this request to the requested app's own static/ dir so a + # traversal in ``path`` cannot escape into the wider apps/ tree. + self.root = os.path.join(self._apps_root, project, "static") + await super().get(path, include_body=include_body) + + async def head( # noqa: A003 - Tornado handler hook + self, project: str, path: str + ) -> None: + # Tornado dispatches every method as method(*path_args), so HEAD must + # accept both capture groups too (project, tail). Delegate to the + # body-less GET path (which sets the per-request ``self.root``), + # mirroring StaticFileHandler.head -> get(path, include_body=False). + await self.get(project, path, include_body=False) + + def validate_absolute_path( + self, root: str, absolute_path: str + ) -> Optional[str]: + # Tornado's own containment check is LEXICAL: it compares + # ``os.path.abspath`` prefixes and never resolves symlinks. So a + # symlink planted inside an app's ``static/`` dir is served whatever it + # points at, including files entirely outside the ``apps/`` tree — an + # unauthenticated arbitrary-file read, strictly worse than the + # traversal the per-request root above closes. Re-run the containment + # test on the SYMLINK-RESOLVED paths. Symlinks that stay inside the + # app's own ``static/`` dir keep working (they resolve inside root). + validated = super().validate_absolute_path(root, absolute_path) + if validated is None: + return None + real_root = os.path.realpath(root) + real_path = os.path.realpath(validated) + if os.path.commonpath((real_root, real_path)) != real_root: + raise tornado.web.HTTPError( + 403, "%s is not in the app's static directory", self.path + ) + return validated + + # --------------------------------------------------------------------------- # Application handlers # --------------------------------------------------------------------------- @@ -282,6 +349,23 @@ async def get(self): self.redirect("/") +class HealthzHandler(tornado.web.RequestHandler): + """ + GET /healthz — unauthenticated process-liveness probe. + + Deliberately a plain handler (NOT ``_AuthMixin``): a liveness probe must + answer for an LB / systemd / k8s without a session, even when auth is + enabled. Process-up only — it performs NO database / provider / event-loop + checks (deep readiness is the app's ``/readyz`` route). Lives in core's + static ``_app_spec`` so it answers for every pyplet app, even when an app + module failed to import (``astart`` swallows app-import errors). + """ + + async def get(self): + self.set_header("Content-Type", "application/json") + self.write({"status": "ok"}) + + class OAuthLoginHandler(tornado.web.RequestHandler): """ GET /oauth/login?provider= — kick off the OAuth flow. @@ -337,11 +421,59 @@ class ServerWebSocket(_AuthMixin, tornado.websocket.WebSocketHandler): closing_message = pyplet.WebSocket.closing_message _is_ws = True + def check_origin(self, origin: str) -> bool: + """Allow same-origin WS upgrades OR the deployed ``PYPLET_URL`` origin. + + Story 18.18 (SECURI-4). Tornado's default ``check_origin`` accepts only + a request whose ``Origin`` host equals the ``Host`` header — which the + edge can break by rewriting ``Host``. We additionally allow an origin + whose host matches the configured deployed origin (``config.url`` / + ``PYPLET_URL``), compared host-only so the edge's scheme/port do not + matter. When ``PYPLET_URL`` is unset (local dev) we fall back to + Tornado's default same-origin result, so ``localhost`` still connects. + + Caveat (documented): Tornado invokes ``check_origin`` ONLY when an + ``Origin`` header is present, so an Origin-less (non-browser) upgrade + is not blocked here — the real gate against anonymous access remains + ``_AuthMixin._require_auth`` in ``open``. + + Args: + origin: The request's ``Origin`` header value. + + Returns: + ``True`` to accept the cross-origin upgrade, ``False`` to reject + (Tornado answers the handshake with 403). + """ + if super().check_origin(origin): + return True + allowed = config.url + if not allowed: + return False + return ( + urllib.parse.urlparse(origin).hostname + == urllib.parse.urlparse(allowed).hostname + ) + + def get_compression_options(self): + """Enable WebSocket ``permessage-deflate`` compression. + + Returning a (possibly empty) dict opts the connection into Tornado's + per-message deflate extension; the client offers the extension and this + handler accepts it during the handshake. ``compression_level`` 6 is + zlib's default speed/ratio trade-off — a good fit for the app's + chatty JSON/text frames without excessive CPU per message. + + Returns: + A dict of compression options enabling ``permessage-deflate``. + """ + return {"compression_level": 6} + async def open(self, project_name, app_name): user = self._require_auth(project_name, app_name) if user is None: self.close(1008, "Unauthorized") return + self.login = user["email"] application = server_applications[project_name, app_name] self.queue = asyncio.Queue() @@ -376,6 +508,7 @@ async def aclose(self): tornado.web.StaticFileHandler, {"path": os.path.join(config.apps, "../pyodide")}, ), + (r"/healthz", HealthzHandler), (r"/", IndexHandler), (r"/about", AboutHandler), (r"/login", LoginHandler), @@ -386,11 +519,13 @@ async def aclose(self): (r"/auth/verify", MagicLinkVerifyHandler), # App static resources (static files) ( - # ONE capture group covering the app name, - # the static folder, and the filename - r"/apps/([a-zA-Z_][a-zA-Z0-9_]*/static/.*)", - tornado.web.StaticFileHandler, - {"path": config.apps}, + # Capture the app project and the file tail SEPARATELY so the + # handler can root itself at //static/ per request + # (see AppStaticFileHandler): a ".." in the tail then cannot escape + # the requested app's own static/ dir into the apps/ tree. + r"/apps/([a-zA-Z_][a-zA-Z0-9_]*)/static/(.*)", + AppStaticFileHandler, + {"apps_root": config.apps}, ), # App upload endpoint (for upload() and upload_area()) ( @@ -420,6 +555,11 @@ async def aclose(self): # server still works without PYPLET_COOKIE_SECRET # (sessions lost on restart). "cookie_secret": config.oauth_cookie_secret or secrets.token_hex(32), + # Max WebSocket frame size. Tornado defaults to ~10 MB, which a base64'd + # document upload exceeds (killing the socket before app code runs). The + # default 40 MB carries a 25 MB upload (~33 MB frame) with headroom; raise + # PYPLET_WS_MAX_MESSAGE_MB in lockstep with any app's per-document cap. + "websocket_max_message_size": config.ws_max_message_mb * 1024 * 1024, } @@ -481,7 +621,118 @@ def _load_server_module(path: str) -> str: return module.__name__ +# --------------------------------------------------------------------------- +# Fail-closed startup policy — production debug guard (Story 18.18, DEPLOY-8) +# --------------------------------------------------------------------------- + + +class DebugConfigError(RuntimeError): + """Raised at startup when the production profile runs with debug on. + + Refusing to boot is intentional: Tornado debug mode enables autoreload and + full traceback pages, which must never be exposed on the production profile + (``PYPLET_REQUIRE_AUTH=1``). Mirrors ``oauth.AuthConfigError``. + """ + + +def enforce_startup_debug_policy() -> None: + """Refuse to boot the production profile with Tornado debug mode on + (DEPLOY-8, Story 18.18). + + On the production profile (``PYPLET_REQUIRE_AUTH=1``) raises + ``DebugConfigError`` when ``PYPLET_DEBUG=1`` (the ``config.py`` default), + because debug mode enables autoreload and exposes traceback pages — leaking + source/stack and re-exec'ing on file change. Off the production profile + (``PYPLET_REQUIRE_AUTH`` unset/``0``) it is a no-op, so debug + autoreload + stay available for the everyday local dev loop. + + The gate is ``config.require_auth`` — NOT ``oauth.auth_enabled()`` — + deliberately mirroring ``oauth.enforce_startup_auth_policy``'s own + production gate. Gating on ``auth_enabled()`` would brick the authenticated + dev loop (a provider client-id set + debug + autoreload), which is a + daily-driver, not production. + + Side effects: reads ``config``. + Raises: ``DebugConfigError`` to abort boot on a production-profile breach. + """ + if config.require_auth == "1" and config.debug == "1": + raise DebugConfigError( + "PYPLET_REQUIRE_AUTH=1 (production profile) but PYPLET_DEBUG=1 — " + "refusing to boot. Tornado debug mode enables autoreload and " + "exposes traceback pages. Set PYPLET_DEBUG=0 in production, or " + "unset PYPLET_REQUIRE_AUTH for an explicitly open local-dev run." + ) + + +def _merge_app_declared_routes() -> list[tuple]: + """Splice app-declared ``routes()`` into ``_app_spec["handlers"]``. + + Every registered application is asked for its ``routes()`` and the + result is inserted BEFORE the catch-all ``r"/.*"`` redirect, which is + the LAST entry of ``_app_spec["handlers"]`` (see the module-level + definition) — a route listed after it would be shadowed into a + redirect and never reached. Insertion is a ``[-1:-1]`` slice + assignment, so the catch-all stays last. + + Called from ``astart()`` once the app modules are loaded (that is what + populates ``server_applications``) and before the Tornado + ``Application`` is built from ``_app_spec`` — a merge after the + Application exists would have no effect on the running server. + + A failing ``routes()`` is logged and skipped so one broken app cannot + take the others down. + + Returns: + The handler tuples that were spliced in (empty list if none). + """ + app_declared_handlers: list[tuple] = [] + for instance in server_applications.values(): + try: + app_declared_handlers.extend(instance.routes()) + except Exception as e: + logger.error( + "Failed to read routes() from %s: %s", + type(instance).__name__, + e, + exc_info=True, + ) + if app_declared_handlers: + _app_spec["handlers"][-1:-1] = app_declared_handlers + logger.info( + "Registered %d app-declared route(s) before catch-all redirect", + len(app_declared_handlers), + ) + return app_declared_handlers + + async def astart(): + # Load all server applications FIRST: importing each *_server.py + # fires ServerApplication.__init_subclass__, which registers the + # instance in server_applications. Anything derived from that + # registry has to run once it is populated, so the modules are + # loaded before the Tornado Application is built from _app_spec. + server_modules = glob.glob(f"{config.apps}/*/*_server.py") + for path in server_modules: + try: + module_name = _load_server_module(path) + logger.debug(f"Loaded module: {module_name}") + except Exception as e: + logger.error(f"Failed to load module {path}: {e}", exc_info=True) + + # Fail-closed auth policy (Story 17.6, PB-1): on the production profile, + # refuse to boot on a misdelivered auth config rather than serve + # anonymously. Runs once the modules are loaded, so the policy sees + # every discovered application. + oauth.enforce_startup_auth_policy(magiclink_enabled=magiclink.enabled()) + + # Story 18.18 (DEPLOY-8): on the production profile, refuse to boot with + # Tornado debug on (autoreload + traceback pages must never ship to prod). + enforce_startup_debug_policy() + + # Merge the routes each app declares into the handler table before the + # Tornado Application is built from _app_spec. + _merge_app_declared_routes() + favicon_uri = None if config.favicon: # Relative paths (e.g. the default "../images/...") are resolved @@ -504,16 +755,10 @@ async def astart(): _app_spec["favicon_data_uri"] = favicon_uri app = tornado.web.Application(**_app_spec) - app.listen(config.port, config.address) - - # Load all server applications - server_modules = glob.glob(f"{config.apps}/*/*_server.py") - for path in server_modules: - try: - module_name = _load_server_module(path) - logger.debug(f"Loaded module: {module_name}") - except Exception as e: - logger.error(f"Failed to load module {path}: {e}", exc_info=True) + # Story 18.18 (DEPLOY-8): trust the edge's X-Forwarded-For / -Proto so the + # app sees the real client IP + https scheme behind the reverse proxy. No + # proxy in local dev ⇒ those headers are absent ⇒ behavior unchanged. + app.listen(config.port, config.address, xheaders=True) url = config.url or f"http://{config.address}:{config.port}" logger.info(f"Pyplet server started on {url}") @@ -1041,10 +1286,33 @@ def poly_issubclass(cls, class_or_tuple): ) } + # Boot splash: a self-contained spinner shown inside #container from + # the very first HTML response, covering the blank window while + # PyScript/Pyodide and the transpiled app load. It carries no Tailwind + # classes (Tailwind loads later, client-side) and no external assets — + # only inline styles plus one " + '
' + "
" + "" + ) + content = { "head": head_content, "body": [ - div(id="container"), + div(id="container")[boot_splash], markupsafe.Markup( # nosec f"