Skip to content

Core improvements - #6

Merged
Vincent-Stragier merged 37 commits into
mainfrom
core-improvements
Aug 28, 2026
Merged

Vincent-Stragier merged 37 commits into
mainfrom
core-improvements

Conversation

@Vincent-Stragier

Copy link
Copy Markdown
Contributor

Add Arthur's core improvements.

aloCetic and others added 30 commits August 21, 2026 17:56
Add `config.site_name` (`PYPLET_SITE_NAME`, default `"Pyplet"`) and read it
at call time for every user-facing product-name literal:

- `templates.py`: `<title>`, navbar wordmark, `About …` nav link, the login
  `Sign in to …` heading, and both `Welcome to …!` index branches;
- `magiclink.py` (`_send_email_sync`): subject, plain body, HTML body and
  button label.

This lets a deployment run under its own name by environment alone, with no
patch to the core. Out of scope on purpose: the `/about` product copy, the
copyright line and the links.

Verified: with the default, the rendered output is byte-for-byte identical to
the parent commit on the 9 affected surfaces (both `base_template` title
branches, both navbar branches, both index branches, login, about, and the
magic-link e-mail) — hence no existing test changes. With
`PYPLET_SITE_NAME=Z2Z`, the name propagates to all of them (25 occurrences)
and the only remaining `Pyplet` string is the out-of-scope `/about` copy.
`astart` built `tornado.web.Application(**_app_spec)` and started listening
before importing the `*_server.py` modules. Since importing a module is what
fires `ServerApplication.__init_subclass__` and registers the instance in
`server_applications`, the registry was still empty when the app was built,
so nothing derived from it could reach `_app_spec`.

Pure reordering: the loader (`_load_server_module`) is unchanged, and the
favicon block still runs immediately before `Application(**_app_spec)`.
No behaviour change for the current handler set.

This is the base the startup policies and the app-declared route merge of
the following changes are wired into.
…aram

The session cookie lifetime was the module-level constant
`_SESSION_MAX_AGE_DAYS = 1` in `oauth.py`, used for both `expires_days`
(set) and `max_age_days` (read). It becomes `config.session_max_age_days`
(`PYPLET_SESSION_TTL_DAYS`, `type_cast=int`), so a deployment can extend
the session without patching the code. Default unchanged: 1 day.

Originally landed on the app branch under the name `CHANI_SESSION_TTL_DAYS`;
renamed here — the core carries no deployment-specific name. (Story 14.6)
Adds an incremental Google OAuth authorization flow that requests the
`drive.file` scope on top of an existing login session:

- `start_drive_consent()` — redirects with `access_type=offline` and
  `prompt=consent` so Google returns a refresh token, and marks the state
  cookie with `"flow": "drive"`;
- `handle_callback()` — routes a `"flow": "drive"` callback to the token
  hooks instead of `set_session()`, leaving the login session untouched;
- `register_drive_token_hook()` — lets a server application register an
  async callback receiving `(sub, email, refresh_token, scopes)`.

The core only carries the flow and the hook registry: storing or
encrypting the refresh token is the application's business.

Landed without the `cryptography` and `google-auth` dependencies the
original commit added: nothing in `pyplet/` or `tests/` imports them, and
the JWKS verification runs on `authlib`, already a dependency. (Story 14.8)
chani's suite uses asyncio_mode=auto + @pytest.mark.asyncio but
pytest-asyncio was undeclared, so a venv resync dropped it and 32 async
tests errored under --strict-markers. Declaring it keeps it across syncs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three ways a misdelivered auth config used to end in a server that served
everything anonymously. On the production profile (`PYPLET_REQUIRE_AUTH=1`)
each one now refuses the boot instead:

- no auth method configured at all;
- `auth_rules.json` missing;
- magic-link enabled without the explicit `PYPLET_ALLOW_MAGICLINK=1` opt-in
  (magic-link mints a session for any e-mail that can receive the link).

`is_app_permitted()` also flips to deny-by-default: with authentication
enabled and no rules file, no app is visible. Authentication fully disabled
(local dev, no provider) keeps allow-all, so an anonymous local run is
unaffected; without `PYPLET_REQUIRE_AUTH` the server still starts and logs a
WARNING that requests are served anonymously.

`enforce_startup_auth_policy()` is called from `astart` once the app modules
are loaded, so the policy sees every discovered application, and before the
Tornado `Application` is built.

+8 tests (`tests/oauth_test.py`). `apps/auth_rules.json` stays the framework
example — this change does not ship any deployment's rules. (Story 17.6)
…cret

Two cookie-level holes on a public deployment:

- session and state cookies carried no `Secure` attribute, so a downgrade to
  http could leak them. `Secure` is now set whenever the deployment is https
  (derived from `PYPLET_URL`), and omitted on plain-http local dev so the
  dev loop keeps working.
- with no `PYPLET_COOKIE_SECRET`, a random secret was minted at each start,
  which silently invalidated every session on restart. On the production
  profile the secret is now required; locally the random fallback stays and
  logs a WARNING.

+14 tests (`tests/oauth_cookie_security_test.py`, `tests/oauth_test.py`).
(Story 17.7)
`PYPLET_COOKIE_SECRET` said only "sessions survive until restart", which
hides the two things an operator needs: the secret is minted *per process*,
and the production profile refuses to boot without it. Spelled out in both
the `Param` description and the `oauth.py` module docstring.

Also documents `PYPLET_SESSION_TTL_DAYS` in the README configuration
reference — it was introduced earlier in this branch without a table entry.
`_decode_id_token_claims()` base64-decoded the id_token payload and trusted
it. A forged token — anything shaped like a JWT — was accepted, so the login
identity came from unauthenticated input.

The token is now verified with `authlib` against the provider's JWKS
(fetched from the OIDC discovery document, cached per provider, refreshed on
an unknown `kid`), checking the signature, `iss`, `aud` and expiry before any
claim is read. `authlib` was already a dependency; no new one is added.

+10 tests (`tests/oauth_id_token_test.py`). (Story 18.19)
A load balancer, systemd unit or k8s probe had no way to tell a live process
from a dead one without authenticating: every route sat behind the auth gate
or the catch-all redirect to `/login`.

`GET /healthz` answers 200 with a small JSON body. `HealthzHandler` is
deliberately a plain `RequestHandler` — it does NOT subclass `_AuthMixin`,
so the probe adds no authenticated surface — and it is registered before the
terminal `r"/.*"` redirect, which stays last.

Liveness only: process-up, no dependency checks (a readiness probe belongs
to the application).

+3 tests (`tests/healthz_test.py`). (Story 18.6)
Tornado caps a WebSocket message at 10 MB and closes the connection past it,
which an app moving large binary frames hits with no recourse other than
patching the server.

`websocket_max_message_size` is now part of `_app_spec`, driven by
`PYPLET_WS_MAX_MESSAGE_MB`. The default is raised to 40 MB — enough for a
25 MB base64'd upload (~33 MB on the wire) with headroom — so this does
change the shipped limit; lower it if a deployment wants Tornado's 10 MB.

+4 tests (`tests/config_test.py`, `tests/server_settings_test.py`).
(Story 18.17)
…ck_origin

Three ways the Tornado layer stayed in dev posture on a public deployment:

- `PYPLET_DEBUG` defaults to `1`, so a deployment that forgot to set it ran
  autoreload and served traceback pages. On the production profile
  (`PYPLET_REQUIRE_AUTH=1`) `enforce_startup_debug_policy()` now refuses the
  boot with `DebugConfigError`. Off that profile it is a no-op, so the local
  dev loop keeps debug + autoreload.
- behind a reverse proxy the app saw the proxy's IP and scheme:
  `app.listen(..., xheaders=True)` trusts `X-Forwarded-For` / `-Proto`. No
  proxy in local dev means the headers are absent and behaviour is unchanged.
- WebSocket `check_origin` accepted any origin. It now allows same-origin
  (Tornado's default) plus the deployed `PYPLET_URL` host, so an edge that
  rewrites `Host` still works while a foreign origin is refused.

The debug policy runs next to the auth policy in `astart`, before the Tornado
`Application` is built.

+10 tests (`tests/prod_hardening_test.py`). (Story 18.18)
The README described the fail-closed auth switch but none of the guards that
came with it: the debug refusal, `xheaders`, the WebSocket origin check and
the `id_token` JWKS verification. Also corrects two stale lines: the
`PYPLET_DEBUG` default now carries its production caveat, and
`PYPLET_WS_MAX_MESSAGE_MB` was missing from the CLI/env list.

Documentation only. Carried in this branch for convenience — it depends on
nothing here and can be dropped without affecting the code.
Two ways the e2e suite tested the wrong thing:

- it bound the default port 8080, routinely already held by another local
  dev server, so the whole suite silently exercised that server instead;
- it inherited whatever OAuth / magic-link credentials an app in `apps/`
  loads from its own `.env` at import time, which flips `auth_enabled()` to
  True — every page then redirects to `/login` and the DOM assertions time
  out.

The fixture now asks the OS for a free ephemeral port and blanks the auth
env for the child (empty strings, not `pop`, so an app-level
`load_dotenv(override=False)` cannot re-populate them). The blind
`time.sleep(3)` is replaced by a connect loop with a 30 s deadline that
fails fast if the child died during import.
An application had no way to serve an HTTP endpoint of its own: the handler
table was fixed at import time in `_app_spec`, so anything app-specific had
to be patched into the core.

`ServerApplication.routes()` (default `[]`) lets an app return
`(url_regex, HandlerClass, init_kwargs)` tuples.
`_merge_app_declared_routes()` splices them into `_app_spec["handlers"]`
just before the terminal catch-all `r"/.*"` redirect, which stays last — a
route landing after it would be shadowed into a 302. The merge runs in
`astart()` after the app modules are loaded (that is what populates
`server_applications`) and before the Tornado `Application` is built: merging
afterwards would leave the running server untouched.

A failing `routes()` is logged and skipped, so one broken app cannot take the
others down. Apps that do not override it get the empty default and the
handler table is unchanged.

Handlers declared this way do NOT go through `_AuthMixin` — an app that wants
its route auth-gated subclasses the mixin explicitly.

+1 test (`tests/server_routes_hook_test.py`): declares an app exposing
`routes()`, asserts the route answers 200 (not a 302 from the catch-all) and
that the catch-all is still the last entry. Checked against two mutations —
splicing after the catch-all, and merging after `Application` — both turn it
red.
``ServerWebSocket.open`` already resolved the caller's identity via
``_require_auth`` but discarded it before launching the app loop, so an app
had no way to know who was on the socket.

Capture it as ``self.login`` right after the resolution and before
``websocket_server_loop`` starts, so a consumer app can read it back with
``getattr(ws, "login", None)`` and thread it into its own session state.

The auth-disabled sentinel's empty-string email is threaded verbatim:
normalizing it to a stable anonymous literal is an app-side concern, not the
framework's.

+3 tests (`tests/server_login_test.py`): resolved user, anonymous sentinel,
and unauthenticated close (no login set).
The ServerWebSocket.get_compression_options override (compression_level 6)
present on the qs-port branches (f083dda / b469ed4) was never carried onto
chani_v3. Without it Tornado's base implementation returns None, which
disables permessage-deflate, so the QualiSpectra story-5.7 regression guards
fail (unit test_permessage_deflate_options.py + integration
test_permessage_deflate_handshake.py): get_compression_options returns None
instead of a dict and the WS handshake advertises no Sec-WebSocket-Extensions.

Restores the exact canonical override so the handshake negotiates the
extension again. Byte-identical to f083dda. Surfaced by the epic-13
story-13.1 verify gate (not a story-13.1 regression — a pre-existing gap on
chani_v3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The window stayed blank for the whole PyScript/Pyodide startup, with no
signal that anything was loading.

Server side: `#container` now carries a self-contained spinner from the very
first HTML response. No Tailwind classes (Tailwind loads later, client-side)
and no external assets — inline styles plus one `<style>` block for the
keyframes, so it renders before any stylesheet arrives, and it honours
`prefers-reduced-motion`.

Client side: `bootstrap_client` removes `#pyplet-boot-splash` by id once the
app module has loaded, so an app that appends to `#container` (instead of
replacing its contents, which drops the splash implicitly) does not leave it
spinning.

No automated test: the splash only exists under a real Pyodide boot, which
`make test` does not exercise. Verified in-browser.
A reload after the app has been used can restore a partial Pyodide package
set from PyScript's IndexedDB cache (`@pyscript.fs` plus the Pyodide package
cache), leaving micropip unregistered — `import micropip` then raises
`ModuleNotFoundError` and the app never boots. The only known workaround was
to clear IndexedDB by hand.

Recover exactly as the error message advises: load the package through the
Pyodide JS API and retry the import. A cold boot never enters the handler, so
the normal path is unchanged.

No automated test: reproducing it needs a real Pyodide runtime with a dirtied
IndexedDB, which `make test` does not exercise. Verified in-browser.
The OIDC engine carried one application's requirements: a `_PROVIDERS` dict
with Google and Microsoft endpoints inline, `register_drive_token_hook()`,
`start_drive_consent()` with `drive.file` + `access_type=offline` frozen in
the source, and a `state["flow"] == "drive"` branch in the callback. An app
needing a different provider or a different extra scope had to patch the
framework.

Two generic entry points replace them:

  register_provider(name, spec)      - an OIDC provider (label, discovery URL,
                                       client id/secret, scopes, auth params).
                                       Values may be zero-arg callables, so a
                                       spec reads config lazily instead of
                                       freezing an env var at import.
  register_consent_flow(name, flow)  - an incremental-consent flow, routed by
                                       `state["flow"]`, with an `on_complete`
                                       hook receiving the raw token response.

The engine itself is untouched: discovery, state cookie, code exchange, JWKS
verification, signed session cookie and the fail-closed startup policy all
keep their behaviour. It now branches on no provider name.

Google and Microsoft move out to `pyplet/server/oauth_providers.py` as shipped
presets, registered at import; a re-registration replaces, which is how an app
overrides one. `config.py` keeps `oauth_google_client_id` and friends — they
are the presets' configuration and are read by the CLI and the login template.

Validation moves to registration time: a spec missing a required key raises
ValueError naming the app, rather than surfacing as a broken production login.
A state cookie naming an unregistered flow is refused with a 400 instead of
falling through into an unrequested login.

Proven equivalent, not assumed: the migrated `drive` flow builds an authorize
request byte-for-byte identical to the one `start_drive_consent()` produced
(same endpoint, same seven parameters, same joined scope string) - checked by
running both implementations side by side.

Tests: 230 -> 250 (20 new in tests/oauth_registry_test.py; none removed).
The migration for the one consumer of the Drive hooks (apps/chani, a separate
repo) is documented in docs/api/oauth-providers.md and NOT applied here.
The drive-consent example claimed the authorization request it produces is
byte-for-byte the one start_drive_consent built. It is not: the Google preset
declares no auth_params, so _provider_auth_params falls back to
_DEFAULT_AUTH_PARAMS ({"prompt": "select_account"}), which claims the "prompt"
slot before the flow's auth_params are merged in. The flow's "prompt":
"consent" then overwrites that earlier slot, hoisting prompt ahead of
access_type in the encoded query string.

The parameter set and every value are identical (8 params, the dicts compare
equal); only the encoding order differs, which is not significant to OAuth.
Reword to claim equivalence rather than byte identity, so the chani team
reading this as a migration reference is not misled into diffing request bytes.
oauth: replace Google/Drive hardcoding with a provider registry

See merge request seglab/pyplet!8
feat(config): make the displayed product name configurable

See merge request seglab/pyplet!3
feat(auth): fail-closed startup, deny-by-default ACL, Secure cookie, JWKS verification

See merge request seglab/pyplet!4
feat(server): production hardening — /healthz, WS frame cap, debug off, xheaders, check_origin

See merge request seglab/pyplet!5
feat(server): routes() hook, WS-resolved login, permessage-deflate

See merge request seglab/pyplet!6
fix(client): boot splash and micropip self-heal during the Pyodide bootstrap

See merge request seglab/pyplet!7
…traversal)

The /apps/<project>/static/<tail> route registered a single Tornado
StaticFileHandler rooted at the whole apps/ tree with a one-group regex.
Tornado's validate_absolute_path only prevents escaping root (apps/), so a
'..' in the captured tail escaped the per-app static/ dir while staying under
apps/ — an unauthenticated path traversal leaking every app's *_server.py
source and auth_rules.json (the ACL).

Fix: capture project and file tail as two groups and root a new
AppStaticFileHandler at <apps>/<project>/static per request, so a traversal
(raw or percent-encoded) can no longer resolve outside the requested app's own
static/ dir. Apps with no static/ dir 404 instead of crashing. Adds a
self-contained AsyncHTTPTestCase regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iFv2TeDxX86EDqgaQyqZG
(cherry picked from commit 27495cb)
…d94cdb)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iFv2TeDxX86EDqgaQyqZG
(cherry picked from commit 830839c)
Tornado's containment test in `validate_absolute_path` is purely lexical
(`abspath` prefix compare, never `realpath`), so confining the route to
`apps/<project>/static/` still served whatever a symlink planted in that
directory pointed at -- including files outside `apps/` entirely, an
unauthenticated arbitrary-file read strictly worse than the traversal the
previous commit fixed. Found by adversarial review of the MR.

Override `validate_absolute_path` to re-check the symlink-RESOLVED paths
with `os.path.commonpath`. Symlinks that stay inside `static/` keep
working; only escapes are refused.

Tests: symlink to a file outside the tree, read through a symlinked
directory, and the HEAD variant of each -- all refused; an internal
symlink still returns 200.
aloCetic and others added 7 commits August 26, 2026 19:37
fix(security): confine app static route to per-app static/ dir (path traversal)

See merge request seglab/pyplet!9
Cherry-picked from the abandoned qs-port branch (f083dda): the feature
itself already landed on main, only its regression test was missing.

Two properties: get_compression_options returns a dict (level 6), and a
real handshake answers 101 with Sec-WebSocket-Extensions naming
permessage-deflate.
test(ws): pin permessage-deflate on the shared ServerWebSocket

See merge request seglab/pyplet!10
docs(readme): document the GitLab->GitHub one-way flow

See merge request seglab/pyplet!12
`parser_start`'s per-param `--<name>` flags defaulted to
`os.environ.get(env_var, SUPPRESS)`, so an omitted flag was
indistinguishable from an explicit one whenever its env var happened to
be set. `main()` then unconditionally `setattr(config, name, value)`s
every populated arg, permanently freezing that value into
`config.__dict__` — which shadows the env var for the rest of the
process, even across later monkeypatched changes in tests.

This broke CI: `PYPLET_DEBUG=1` is set job-wide, so any cli_test.py
test invoking `main(["start", ...])` froze `config.debug = "1"`,
causing prod_hardening_test.py's later
`monkeypatch.setenv("PYPLET_DEBUG", "0")` to be silently ignored.

Default to `argparse.SUPPRESS` unconditionally instead — `config`'s own
Param descriptor already reads the env var live on every access when
not explicitly overridden, so seeding argparse's default from it here
was redundant as well as harmful.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`Param.__set__` unconditionally writes an instance override, even when
the value being written matches what was there before. So the common
test pattern `original = config.x; ...; config.x = original` doesn't
actually restore the *original resolution behavior* (dynamic:
instance dict -> env var -> default) — it just re-freezes `config.x`
to whatever it happened to read at test start, permanently shadowing
that param's env var for the rest of the process.

This silently broke the e2e `server` fixture (tests/conftest.py):
cli_test.py's TestCLIConfigOverrides tests "restore" config.port /
config.address / config.apps this way, so after they ran,
config.port stayed frozen — the server fixture's forked child sets
PYPLET_PORT before calling astart(), expecting config.port to read it
live, but got the frozen value instead and bound the wrong port
(observed as "Pyplet test server not reachable" for all 12
template_app_test.py tests, with the log showing the server actually
started on :8080 instead of the assigned free port).

Fix:
- Add Param.__delete__ to config.py, so an instance override can
  actually be cleared (not just reassigned to an old value).
- Replace the three affected cli_test.py "restore" spots (plus
  package_test.py's restore_config_apps fixture) with a full
  config.__dict__ snapshot/restore, which correctly undoes an
  override introduced mid-test instead of re-freezing it.
- Add a regression test characterizing the exact failure mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Vincent-Stragier
Vincent-Stragier merged commit dea1715 into main Aug 28, 2026
4 checks passed
@aloCetic
aloCetic deleted the core-improvements branch September 2, 2026 09:33
aloCetic pushed a commit that referenced this pull request Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants