diff --git a/keel/freeze.py b/keel/freeze.py index 2de8268..a6722fb 100644 --- a/keel/freeze.py +++ b/keel/freeze.py @@ -1,6 +1,6 @@ """What a frozen bundle has to be told, because PyInstaller cannot work it out (#438). -Four things break when keel is frozen, and three of them break SILENTLY. That is what makes this +Five things break when keel is frozen, and four of them break SILENTLY. That is what makes this module worth having rather than a handful of flags in a build script: every one of these was found by building a bundle and running it, and every one of them produced a binary that started cleanly and was wrong. @@ -23,6 +23,15 @@ **4. The config templates are package DATA.** `init-config` reads them through `importlib.resources`; without them a first run cannot write a config at all. +**5. The web UI's static assets are package DATA too (#535).** `keel/web/staticfiles.py` finds +them with `Path(__file__).parent / "static"` rather than `importlib.resources` -- a plain +filesystem lookup relative to the module's OWN frozen location, which resolves correctly only if +PyInstaller actually copied `keel/web/static/` alongside the frozen `staticfiles.py`. Without +this, `keel serve` binds and every rendered page loads (they carry no static reference), so the +bundle looks healthy right up until #536's client -- or anyone hitting `/static/*` today -- gets +a 404 with no terminal to diagnose it from, on the exact double-click path #535 exists to make +work. + Everything here is computed from the build environment rather than hardcoded. A hardcoded list is the same failure one release later: adding a fifth adapter and forgetting to add it here would produce exactly the silent `0 adapter(s)` bundle that (2) describes. @@ -35,6 +44,15 @@ #: The package holding `config.yaml` / `config.live.yaml`, read via `importlib.resources`. TEMPLATE_PACKAGE = "keel.templates" +#: The package holding the web UI's static assets (#535), read via a plain filesystem path +#: (`keel/web/staticfiles.py`'s `STATIC_ROOT`), not `importlib.resources` -- so unlike +#: `TEMPLATE_PACKAGE` this name never needs to reach `hidden_imports()`: nothing anywhere calls +#: `importlib.import_module` or `importlib.resources.files` on it by string. It still has to +#: reach `collect_data` below, because that is the step that copies the directory into the +#: bundle at all; naming it here is what stops that copy from being forgotten the way the +#: config templates already were once (see the module docstring's point 5). +STATIC_PACKAGE = "keel.web.static" + def _module_of(distribution: str) -> str: """`keel-broker-fake` -> `keel_broker_fake`. The import package for a keel distribution is @@ -98,5 +116,5 @@ def freeze_inputs() -> dict[str, tuple[str, ...]]: return { "hiddenimports": hidden_imports(), "copy_metadata": metadata_distributions(), - "collect_data": (TEMPLATE_PACKAGE,), + "collect_data": (TEMPLATE_PACKAGE, STATIC_PACKAGE), } diff --git a/keel/web/server.py b/keel/web/server.py index 582254b..1e306d5 100644 --- a/keel/web/server.py +++ b/keel/web/server.py @@ -45,7 +45,7 @@ from typing import Any from urllib.parse import parse_qs, quote, urlsplit -from keel.web import render +from keel.web import render, staticfiles from keel.web.security import ( SESSION_COOKIE, HostPolicy, @@ -309,10 +309,16 @@ def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, } -#: The write surface, in full. A path here maps to one `keel.commands.setup.Action`; there is no -#: other way into this handler, and no other verb. +#: The write surface, in full, today. A path here maps to one `keel.commands.setup.Action`; there +#: is no other way into this handler, and no other verb. SETUP_ACTION_PREFIX = "/setup/" +#: Reserved for #533/#534's JSON API and #536's fetch()-based client -- nothing is mapped under +#: it yet, so a POST here is a 404 like any other unmapped path. Named now because #535's third +#: CSRF layer (`X-Keel-Client`, checked in `_client_header_ok`) is scoped to it specifically: see +#: that method's docstring for why it must NOT also gate `SETUP_ACTION_PREFIX`. +API_PREFIX = "/api/" + def run_setup_action(cfg: ServeConfig, key: str, form: dict[str, str]) -> Any: """Perform one declared setup action. Returns its `ActionResult`, or `None` for a key that is @@ -365,6 +371,87 @@ def _close(repo: Any) -> None: ) +#: The header set for `/static/*` (#535), separate from `_SECURITY_HEADERS` above because the +#: two routes need different values for the SAME header, not merely an additional one. +#: `_SECURITY_HEADERS`'s `default-src 'none'` is correct for the rendered pages -- they ship no +#: script, no style file, no image, nothing to permit -- but #536's client is exactly the thing +#: `'none'` forbids: its own JS, its own CSS, its own icons, all same-origin. `'self'` is the +#: tightest policy that still allows that, and `connect-src 'self'` on top of it is the specific +#: guarantee the design spec asks for: the interface is provably incapable of sending positions, +#: equity or trade history anywhere but this local process, checkable in the response headers +#: rather than merely promised. +#: +#: `X-Frame-Options`, `Referrer-Policy` and `X-Content-Type-Options` are unconditional -- all +#: three are meaningful (and harmless) on any content type, exactly as they are for the rendered +#: pages above. CSP is NOT: RFC-wise it is a response header with no defined meaning outside a +#: browsing context, so it is applied only where the content type IS one -- `text/html`, and +#: `image/svg+xml` (see `_CSP_CONTENT_TYPES` and `_static_headers` below) -- matching the design +#: spec's "CSP belongs on `text/html` responses only; it is invalid and discouraged on other +#: content types." +_STATIC_BASE_HEADERS: tuple[tuple[str, str], ...] = ( + ("X-Content-Type-Options", "nosniff"), + ("X-Frame-Options", "DENY"), + ("Referrer-Policy", "no-referrer"), + # `_serve_static` writes its own headers rather than going through `_send` (below), which is + # what let this go missing initially: `_send` puts `Cache-Control: no-store` on every + # rendered page, and that omission here meant a browser was free to heuristically cache a + # static asset with NO explicit directive at all. Harmless for today's one placeholder file, + # but #536 ships a real client next, and a stale shell surviving an engine upgrade in the + # HTTP cache is exactly the failure the design spec's service-worker cache key (keyed to + # `/api/config`'s build version) exists to prevent one layer up -- this is the layer below + # it. `no-store` for now is the conservative match to the rendered pages; a `CacheFirst` + # strategy with content-hashed filenames is #536's to add once there is a build version to + # key it to. + ("Cache-Control", "no-store, max-age=0"), +) + +#: `default-src 'self'; connect-src 'self'` alone was the shape reviewed in and it was +#: incomplete: `form-action`, `base-uri` and `frame-ancestors` do NOT fall back to `default-src` +#: under CSP3 -- each is independently permissive (`form-action` defaults to "anywhere", +#: `base-uri` to "anywhere", `frame-ancestors` to "anywhere") unless named explicitly. Without +#: them, `connect-src 'self'` still stops `fetch`/`XHR`/`EventSource` leaving the origin, but a +#: `