This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch taint_tracking_types
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 35c6670f97a4fb948290aa6622fc765789649dfe
Author: Alastair McFarlane <[email protected]>
AuthorDate: Thu Feb 26 16:47:22 2026 +0000

    Add taint tracking types to post endpoints
---
 atr/blueprints/common.py |  69 ++++++++++-----
 atr/blueprints/get.py    |  16 ++--
 atr/blueprints/post.py   | 217 ++++++++++++++---------------------------------
 atr/get/distribution.py  |  12 +--
 atr/post/announce.py     |  44 +++++-----
 atr/post/distribution.py |  91 ++++++++++++++------
 atr/post/draft.py        | 196 +++++++++++++++++++++++++++---------------
 atr/post/finish.py       |  23 +++--
 atr/post/ignores.py      |  22 +++--
 atr/post/keys.py         |  93 ++++++++++++++------
 atr/post/manual.py       |  63 ++++++++------
 atr/post/projects.py     |  36 +++++---
 atr/post/resolve.py      |  21 +++--
 atr/post/revisions.py    |  20 +++--
 atr/post/sbom.py         |  22 +++--
 atr/post/start.py        |  22 +++--
 atr/post/test.py         |  48 +++++++----
 atr/post/tokens.py       |  21 +++--
 atr/post/upload.py       |  56 +++++++-----
 atr/post/user.py         |  11 ++-
 atr/post/vote.py         |  21 +++--
 atr/post/voting.py       |  59 +++++++------
 22 files changed, 698 insertions(+), 485 deletions(-)

diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
index 0428b83d..ccdca547 100644
--- a/atr/blueprints/common.py
+++ b/atr/blueprints/common.py
@@ -17,13 +17,14 @@
 
 import inspect
 from collections.abc import Callable
-from typing import Any, Literal, get_args, get_origin, get_type_hints
+from typing import Annotated, Any, Literal, TypeAliasType, get_args, 
get_origin, get_type_hints
 
 import asfquart.base as base
 import asfquart.session
 
 import atr.cache as cache
 import atr.db as db
+import atr.form as form
 import atr.ldap as ldap
 import atr.models.safe as safe
 import atr.models.sql as sql
@@ -60,12 +61,16 @@ async def authenticate_public() -> web.Public:
             return None
 
 
-def build_path(func: Callable[..., Any]) -> tuple[str, list[tuple[str, type]], 
dict[str, str], bool]:
+def build_path(
+    func: Callable[..., Any],
+) -> tuple[str, list[tuple[str, type]], dict[str, str], tuple[str, type] | 
None, bool]:
     """Inspect a function's type hints to build a URL path and a validation 
plan.
 
-    Returns (path, validated_params, literal_params, public) where 
validated_params is a
-    list of (param_name, param_type) for each parameter that needs async
-    validation, literal_params maps parameter names to their values, and 
public is whether the route is public or not
+    Returns (path, validated_params, literal_params, form_param, public) where:
+    - validated_params: (name, type) pairs for URL params validated via 
cache/DB
+    - literal_params: param name → literal string value for Literal["..."] 
params
+    - form_param: (name, type) for the single form.Form subclass param, or None
+    - public: True if the session type is web.Public
     """
     hints = get_type_hints(func, include_extras=True)
     params = list(inspect.signature(func).parameters.keys())
@@ -73,38 +78,34 @@ def build_path(func: Callable[..., Any]) -> tuple[str, 
list[tuple[str, type]], d
     segments: list[str] = []
     validated_params: list[tuple[str, type]] = []
     literal_params: dict[str, str] = {}
+    form_param: tuple[str, type] | None = None
 
     for ix, param_name in enumerate(params):
         hint = hints.get(param_name)
         if hint is None:
             raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
no type annotation")
 
-        # This is the session object, which should be first
         if param_name == "session":
             if ix != 0:
                 raise TypeError(f"Parameter {param_name!r} in {func.__name__} 
must be first")
             public = hint is web.Public
             continue
 
-        origin = get_origin(hint)
+        if _is_form_type(hint):
+            if form_param is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one Form is allowed")
+            form_param = (param_name, hint)
+            continue
 
-        if origin is Literal:
-            literal_value = get_args(hint)[0]
-            segments.append(str(literal_value))
-            literal_params[param_name] = str(literal_value)
-        elif hint in VALIDATED_TYPES:
-            segments.append(f"<{param_name}>")
+        segment = _param_to_segment(param_name, hint, func.__name__)
+        segments.append(segment)
+        if hint in VALIDATED_TYPES:
             validated_params.append((param_name, hint))
-        elif hint in QUART_CONVERTERS:
-            converter = QUART_CONVERTERS[hint]
-            segments.append(f"<{converter}:{param_name}>")
-        elif hint is str:
-            segments.append(f"<{param_name}>")
-        else:
-            raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
unsupported type {hint!r}")
+        elif get_origin(hint) is Literal:
+            literal_params[param_name] = str(get_args(hint)[0])
 
     path = "/" + "/".join(segments)
-    return path, validated_params, literal_params, public
+    return path, validated_params, literal_params, form_param, public
 
 
 def register_route(func: Callable[..., Any], prefix: str, routes: list[str]) 
-> None:
@@ -146,3 +147,29 @@ async def validate_version(project_name: safe.ProjectName, 
raw: str) -> safe.Ver
     if release is None:
         raise base.ASFQuartException(f"Version {raw!r} not found for project 
{project_name!s}", errorcode=404)
     return safe.VersionName(release.version)
+
+
+def _is_form_type(hint: Any) -> bool:
+    """Check if a type hint represents a form.Form subclass or Annotated 
discriminated union of forms."""
+    if isinstance(hint, type) and issubclass(hint, form.Form):
+        return True
+    # Unwrap TypeAliasType to get the underlying type
+    if isinstance(hint, TypeAliasType):
+        hint = hint.__value__
+    if get_origin(hint) is Annotated:
+        args = get_args(hint)
+        return len(args) >= 2 and form.DISCRIMINATOR in args[1:]
+    return False
+
+
+def _param_to_segment(param_name: str, hint: Any, func_name: str) -> str:
+    """Convert a single parameter's type hint into a URL path segment."""
+    if get_origin(hint) is Literal:
+        return str(get_args(hint)[0])
+    if hint in VALIDATED_TYPES:
+        return f"<{param_name}>"
+    if hint in QUART_CONVERTERS:
+        return f"<{QUART_CONVERTERS[hint]}:{param_name}>"
+    if hint is str:
+        return f"<{param_name}>"
+    raise TypeError(f"Parameter {param_name!r} in {func_name} has unsupported 
type {hint!r}")
diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index c978add0..cd586505 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -37,6 +37,13 @@ _P = ParamSpec("_P")
 _R = TypeVar("_R")
 
 
+def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
+    import atr.get as get
+
+    app.register_blueprint(_BLUEPRINT)
+    return get, _routes
+
+
 @overload
 def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) 
-> web.RouteFunction[R]: ...
 
@@ -55,7 +62,7 @@ def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
     - str parameters pass through as-is
     - check_access is called automatically for committer routes with 
project_name
     """
-    path, validated_params, literal_params, public = common.build_path(func)
+    path, validated_params, literal_params, _, public = common.build_path(func)
     project_name_var = next((name for name, type_name in validated_params if 
type_name is safe.ProjectName), None)
     check_access = not public and (project_name_var is not None)
 
@@ -89,10 +96,3 @@ def typed(func: Callable[..., Any]) -> 
web.RouteFunction[Any]:
     common.register_route(func, "get", _routes)
 
     return decorated
-
-
-def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
-    import atr.get as get
-
-    app.register_blueprint(_BLUEPRINT)
-    return get, _routes
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index ac20af18..8224d281 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -19,17 +19,17 @@ import json
 import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
-from typing import Any
+from typing import Any, Concatenate, overload
 
 import asfquart.auth as auth
 import asfquart.base as base
-import asfquart.session
 import pydantic
 import quart
 
+import atr.blueprints.common as common
 import atr.form
-import atr.ldap as ldap
 import atr.log as log
+import atr.models.safe as safe
 import atr.web as web
 
 _BLUEPRINT_NAME = "post_blueprint"
@@ -37,171 +37,82 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 _routes: list[str] = []
 
 
-async def _authenticate() -> web.Committer:
-    web_session = await asfquart.session.read()
-    if web_session is None:
-        raise base.ASFQuartException("Not authenticated", errorcode=401)
-    if (web_session.uid is None) or (not await 
ldap.is_active(web_session.uid)):
-        asfquart.session.clear()
-        raise base.ASFQuartException("Account is disabled", errorcode=401)
-    return web.Committer(web_session)
-
-
-def _register(func: Callable[..., Any]) -> None:
-    module_name = func.__module__.split(".")[-1]
-    _routes.append(f"post.{module_name}.{func.__name__}")
-
-
-def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], 
web.RouteFunction[Any]]:
-    def decorator(func: web.CommitterRouteFunction[Any]) -> 
web.RouteFunction[Any]:
-        async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            enhanced_session = await _authenticate()
-            start_time_ns = time.perf_counter_ns()
-            response = await func(enhanced_session, *args, **kwargs)
-            end_time_ns = time.perf_counter_ns()
-            total_ns = end_time_ns - start_time_ns
-            total_ms = total_ns // 1_000_000
-
-            # TODO: Make this configurable in config.py
-            log.performance(
-                f"POST {path} {func.__name__} = 0 0 {total_ms}",
-            )
-
-            return response
-
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
-        wrapper.__name__ = func.__name__
-        wrapper.__doc__ = func.__doc__
-        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
-
-        decorated = auth.require(auth.Requirements.committer)(wrapper)
-        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["POST"])
-        _register(func)
-
-        return decorated
-
-    return decorator
-
-
-def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., 
Awaitable[Any]]]:
-    # This means that instead of:
-    #
-    # @post.form(form.Empty)
-    # async def test_empty(
-    #     session: web.Public,
-    #     form: form.Empty,
-    # ) -> web.WerkzeugResponse:
-    #     pass
-    #
-    # We can use:
-    #
-    # @post.empty()
-    # async def test_empty(
-    #     session: web.Public,
-    # ) -> web.WerkzeugResponse:
-    #     pass
-    def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., 
Awaitable[Any]]:
-        async def wrapper(session: web.Committer | None, *args: Any, **kwargs: 
Any) -> Any:
-            match session:
-                case web.Committer() as committer:
-                    form_data = await committer.form_data()
-                case None:
-                    form_data = await atr.form.quart_request()
-            try:
-                context = {
-                    "args": args,
-                    "kwargs": kwargs,
-                    "session": session,
-                }
-                match session:
-                    case web.Committer() as committer:
-                        await committer.form_validate(atr.form.Empty, 
context=context)
-                    case None:
-                        atr.form.validate(atr.form.Empty, form_data, 
context=context)
-                return await func(session, *args, **kwargs)
-            except pydantic.ValidationError:
-                # This could happen if the form was tampered with
-                # It should not happen if the CSRF token is invalid
-                msg = "Sorry, there was an empty form validation error. Please 
try again."
-                await quart.flash(msg, "error")
-                return quart.redirect(quart.request.path)
+def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
+    import atr.post as post
+
+    app.register_blueprint(_BLUEPRINT)
+    return post, _routes
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) 
-> web.RouteFunction[R]: ...
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Public, P], Awaitable[R]]) -> 
web.RouteFunction[R]: ...  # pyright: ignore[reportOverlappingOverload]
+
 
-        wrapper.__annotations__ = func.__annotations__.copy()
-        wrapper.__doc__ = func.__doc__
-        wrapper.__module__ = func.__module__
-        wrapper.__name__ = func.__name__
-        return wrapper
-
-    return decorator
-
-
-def form(
-    form_cls: Any,
-) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
-    def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., 
Awaitable[Any]]:
-        async def wrapper(session: web.Committer | None, *args: Any, **kwargs: 
Any) -> Any:
-            match session:
-                case web.Committer() as committer:
-                    form_data = await committer.form_data()
-                case None:
-                    form_data = await atr.form.quart_request()
+def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
+    """Decorator that derives the URL path from the function's type 
annotations.
+
+    - Arguments after session are joined with / to make the web path
+    - Literal["..."] parameters become literal path segments
+    - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
+    - int, float use Quart's built-in type converters
+    - str parameters pass through as-is
+    - A single form.Form subclass parameter is validated from the request body 
and injected
+    - check_access is called automatically for committer routes with 
project_name
+    """
+    path, validated_params, literal_params, form_param, public = 
common.build_path(func)
+    project_name_var = next((name for name, t in validated_params if t is 
safe.ProjectName), None)
+    check_access = not public and (project_name_var is not None)
+
+    async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+        enhanced_session = await common.authenticate_public() if public else 
await common.authenticate()
+        await common.run_validators(kwargs, validated_params)
+        kwargs.update(literal_params)
+
+        if check_access and enhanced_session is not None and project_name_var 
is not None:
+            await enhanced_session.check_access(str(kwargs[project_name_var]))
+
+        if form_param is not None:
+            form_param_name, form_cls = form_param
+            context: dict[str, Any] = {"kwargs": kwargs, "session": 
enhanced_session}
             try:
-                context = {
-                    "args": args,
-                    "kwargs": kwargs,
-                    "session": session,
-                }
-                match session:
+                match enhanced_session:
                     case web.Committer() as committer:
-                        validated_form = await 
committer.form_validate(form_cls, context)
+                        kwargs[form_param_name] = await 
committer.form_validate(form_cls, context)
                     case None:
-                        validated_form = atr.form.validate(form_cls, 
form_data, context=context)
-                return await func(session, validated_form, *args, **kwargs)
+                        form_data = await atr.form.quart_request()
+                        kwargs[form_param_name] = atr.form.validate(form_cls, 
form_data, context=context)
             except pydantic.ValidationError as e:
                 errors = e.errors()
                 if len(errors) == 0:
                     raise RuntimeError("Validation failed, but no errors were 
reported")
-                flash_data = atr.form.flash_error_data(form_cls, errors, 
form_data)
+                form_data_raw = await atr.form.quart_request()
+                flash_data = atr.form.flash_error_data(form_cls, errors, 
form_data_raw)
                 summary = atr.form.flash_error_summary(errors, flash_data)
-
-                # TODO: Centralise all uses of markupsafe.Markup
-                # log.info(f"Flash data: {flash_data}")
                 await quart.flash(summary, category="error")
                 await quart.flash(json.dumps(flash_data), 
category="form-error-data")
                 return quart.redirect(quart.request.path)
 
-        wrapper.__annotations__ = func.__annotations__.copy()
-        wrapper.__doc__ = func.__doc__
-        wrapper.__module__ = func.__module__
-        wrapper.__name__ = func.__name__
-        return wrapper
-
-    return decorator
-
+        start_time_ns = time.perf_counter_ns()
+        response = await func(enhanced_session, **kwargs)
+        end_time_ns = time.perf_counter_ns()
+        total_ns = end_time_ns - start_time_ns
+        total_ms = total_ns // 1_000_000
 
-def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], 
web.RouteFunction[Any]]:
-    def decorator(func: Callable[..., Awaitable[Any]]) -> 
web.RouteFunction[Any]:
-        async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            enhanced_session = web.Committer(web_session) if web_session else 
None
-            return await func(enhanced_session, *args, **kwargs)
+        log.performance(f"POST {path} {func.__name__} = 0 0 {total_ms}")
 
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
-        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
-        wrapper.__doc__ = func.__doc__
-        wrapper.__module__ = func.__module__
-        wrapper.__name__ = func.__name__
+        return response
 
-        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["POST"])
-        _register(func)
+    endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
-        return wrapper
+    decorated = wrapper if public else 
auth.require(auth.Requirements.committer)(wrapper)
+    _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["POST"])
+    common.register_route(func, "post", _routes)
 
-    return decorator
-
-
-def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
-    import atr.post as post
-
-    app.register_blueprint(_BLUEPRINT)
-    return post, _routes
+    return decorated
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 82017a2f..3b608ca2 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -120,7 +120,9 @@ async def list_get(
 
         delete_form = form.render(
             model_cls=shared.distribution.DeleteForm,
-            action=util.as_url(post.distribution.delete, 
project=str(project_name), version=str(version_name)),
+            action=util.as_url(
+                post.distribution.delete, project_name=str(project_name), 
version_name=str(version_name)
+            ),
             form_classes=".d-inline-block.m-0",
             submit_classes="btn-danger btn-sm",
             submit_label="Delete",
@@ -202,9 +204,9 @@ async def _automate_form_page(project: str, version: str, 
staging: bool) -> str:
 
     # Determine the action based on staging
     action = (
-        util.as_url(post.distribution.stage_automate_selected, 
project=project, version=version)
+        util.as_url(post.distribution.stage_automate_selected, 
project_name=project, version_name=version)
         if staging
-        else util.as_url(post.distribution.automate_selected, project=project, 
version=version)
+        else util.as_url(post.distribution.automate_selected, 
project_name=project, version_name=version)
     )
 
     # TODO: Reuse the same form for now - maybe we can combine this and the 
function below adding an automate=True arg
@@ -275,9 +277,9 @@ async def _record_form_page(project: str, version: str, 
staging: bool) -> str:
 
     # Determine the action based on staging
     action = (
-        util.as_url(post.distribution.stage_record_selected, project=project, 
version=version)
+        util.as_url(post.distribution.stage_record_selected, 
project_name=project, version_name=version)
         if staging
-        else util.as_url(post.distribution.record_selected, project=project, 
version=version)
+        else util.as_url(post.distribution.record_selected, 
project_name=project, version_name=version)
     )
 
     # Render the distribution form
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 1513e94a..417c0dd6 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -17,10 +17,12 @@
 
 from __future__ import annotations
 
-# TODO: Improve upon the routes_release pattern
+from typing import Literal
+
 import atr.blueprints.post as post
 import atr.construct as construct
 import atr.get as get
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
@@ -28,14 +30,18 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/announce/<project_name>/<version_name>")
[email protected](shared.announce.AnnounceForm)
[email protected]
 async def selected(
-    session: web.Committer, announce_form: shared.announce.AnnounceForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _announce: Literal["announce"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    announce_form: shared.announce.AnnounceForm,
 ) -> web.WerkzeugResponse:
-    """Handle the announcement form submission and promote the preview to 
release."""
-    await session.check_access(project_name)
-
+    """
+    URL: /announce/<project_name>/<version_name>
+    Handle the announcement form submission and promote the preview to release.
+    """
     permitted_recipients = util.permitted_announce_recipients(session.uid)
 
     # Validate that the recipient is permitted
@@ -47,8 +53,8 @@ async def selected(
 
     # Get the release to find the revision number
     release = await session.release(
-        project_name,
-        version_name,
+        str(project_name),
+        str(version_name),
         with_committee=True,
         phase=sql.ReleasePhase.RELEASE_PREVIEW,
         with_distributions=True,
@@ -63,8 +69,8 @@ async def selected(
             get.announce.selected,
             error=f"The release has been updated since you loaded the form. "
             f"Please review the current revision ({preview_revision_number}) 
and submit the form again.",
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
         )
 
     policy = release.release_policy or release.project.release_policy
@@ -81,12 +87,12 @@ async def selected(
                 error=f"This release cannot be announced until the following 
distributions have been recorded: {
                     ', '.join(missing)
                 }",
-                project_name=project_name,
-                version_name=version_name,
+                project_name=str(project_name),
+                version_name=str(version_name),
             )
 
     # Validate that the subject template hasn't changed
-    subject_template = await 
construct.announce_release_subject_default(project_name)
+    subject_template = await 
construct.announce_release_subject_default(str(project_name))
     current_hash = construct.template_hash(subject_template)
     if current_hash != announce_form.subject_template_hash:
         return await session.form_error(
@@ -95,10 +101,10 @@ async def selected(
         )
 
     try:
-        async with storage.write_as_project_committee_member(project_name, 
session) as wacm:
+        async with 
storage.write_as_project_committee_member(str(project_name), session) as wacm:
             await wacm.announce.release(
-                project_name=project_name,
-                version_name=version_name,
+                project_name=str(project_name),
+                version_name=str(version_name),
                 preview_revision_number=preview_revision_number,
                 recipient=announce_form.mailing_list,
                 body=announce_form.body,
@@ -109,12 +115,12 @@ async def selected(
             )
     except storage.AccessError as e:
         return await session.redirect(
-            get.announce.selected, error=str(e), project_name=project_name, 
version_name=version_name
+            get.announce.selected, error=str(e), 
project_name=str(project_name), version_name=str(version_name)
         )
 
     routes_release_finished = get.release.finished
     return await session.redirect(
         routes_release_finished,
         success="Preview successfully announced",
-        project_name=project_name,
+        project_name=str(project_name),
     )
diff --git a/atr/post/distribution.py b/atr/post/distribution.py
index e3936821..e31f77dc 100644
--- a/atr/post/distribution.py
+++ b/atr/post/distribution.py
@@ -17,13 +17,14 @@
 
 from __future__ import annotations
 
-from typing import Final
+from typing import Final, Literal
 
 import atr.blueprints.post as post
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.get as get
 import atr.models.distribution as distribution
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
@@ -50,8 +51,8 @@ async def automate_form_process_page(
         platform_str = form_data.platform.value
         return await session.redirect(
             get.distribution.stage_automate if staging else 
get.distribution.automate,
-            project=project,
-            version=version,
+            project_name=project,
+            version_name=version,
             error=f"Platform {platform_str} is not supported for automated 
distribution",
         )
     sql_platform = form_data.platform.to_sql()  # type: ignore[attr-defined]
@@ -92,8 +93,8 @@ async def automate_form_process_page(
             # Instead of calling record_form_page_new, redirect with error 
message
             return await session.redirect(
                 get.distribution.stage_automate if staging else 
get.distribution.automate,
-                project=project,
-                version=version,
+                project_name=project,
+                version_name=version,
                 error=str(e),
             )
 
@@ -107,19 +108,33 @@ async def automate_form_process_page(
     )
 
 
[email protected]("/distribution/automate/<project>/<version>")
[email protected](shared.distribution.DistributeForm)
[email protected]
 async def automate_selected(
-    session: web.Committer, distribute_form: 
shared.distribution.DistributeForm, project: str, version: str
+    session: web.Committer,
+    _distribution_automate: Literal["distribution/automate"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    distribute_form: shared.distribution.DistributeForm,
 ) -> web.WerkzeugResponse:
-    return await automate_form_process_page(session, distribute_form, project, 
version, staging=False)
+    """
+    URL: /distribution/automate/<project_name>/<version_name>
+    """
+    return await automate_form_process_page(
+        session, distribute_form, str(project_name), str(version_name), 
staging=False
+    )
 
 
[email protected]("/distribution/delete/<project>/<version>")
[email protected](shared.distribution.DeleteForm)
[email protected]
 async def delete(
-    session: web.Committer, delete_form: shared.distribution.DeleteForm, 
project: str, version: str
+    session: web.Committer,
+    _distribution_delete: Literal["distribution/delete"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    delete_form: shared.distribution.DeleteForm,
 ) -> web.WerkzeugResponse:
+    """
+    URL: /distribution/delete/<project_name>/<version_name>
+    """
     sql_platform = delete_form.platform.to_sql()  # type: ignore[attr-defined]
 
     # Validate the submitted data, and obtain the committee for its name
@@ -142,8 +157,8 @@ async def delete(
         )
     return await session.redirect(
         get.distribution.list_get,
-        project_name=project,
-        version_name=version,
+        project_name=str(project_name),
+        version_name=str(version_name),
         success="Distribution deleted",
     )
 
@@ -182,8 +197,8 @@ async def record_form_process_page(
             # Instead of calling record_form_page_new, redirect with error 
message
             return await session.redirect(
                 get.distribution.stage_record if staging else 
get.distribution.record,
-                project=project,
-                version=version,
+                project_name=project,
+                version_name=version,
                 error=str(e),
             )
 
@@ -197,25 +212,45 @@ async def record_form_process_page(
     )
 
 
[email protected]("/distribution/record/<project>/<version>")
[email protected](shared.distribution.DistributeForm)
[email protected]
 async def record_selected(
-    session: web.Committer, distribute_form: 
shared.distribution.DistributeForm, project: str, version: str
+    session: web.Committer,
+    _distribution_record: Literal["distribution/record"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    distribute_form: shared.distribution.DistributeForm,
 ) -> web.WerkzeugResponse:
-    return await record_form_process_page(session, distribute_form, project, 
version, staging=False)
+    """
+    URL: /distribution/record/<project_name>/<version_name>
+    """
+    return await record_form_process_page(session, distribute_form, 
str(project_name), str(version_name), staging=False)
 
 
[email protected]("/distribution/stage/automate/<project>/<version>")
[email protected](shared.distribution.DistributeForm)
[email protected]
 async def stage_automate_selected(
-    session: web.Committer, distribute_form: 
shared.distribution.DistributeForm, project: str, version: str
+    session: web.Committer,
+    _distribution_stage_automate: Literal["distribution/stage/automate"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    distribute_form: shared.distribution.DistributeForm,
 ) -> web.WerkzeugResponse:
-    return await automate_form_process_page(session, distribute_form, project, 
version, staging=True)
+    """
+    URL: /distribution/stage/automate/<project_name>/<version_name>
+    """
+    return await automate_form_process_page(
+        session, distribute_form, str(project_name), str(version_name), 
staging=True
+    )
 
 
[email protected]("/distribution/stage/record/<project>/<version>")
[email protected](shared.distribution.DistributeForm)
[email protected]
 async def stage_record_selected(
-    session: web.Committer, distribute_form: 
shared.distribution.DistributeForm, project: str, version: str
+    session: web.Committer,
+    _distribution_stage_record: Literal["distribution/stage/record"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    distribute_form: shared.distribution.DistributeForm,
 ) -> web.WerkzeugResponse:
-    return await record_form_process_page(session, distribute_form, project, 
version, staging=True)
+    """
+    URL: /distribution/stage/record/<project_name>/<version_name>
+    """
+    return await record_form_process_page(session, distribute_form, 
str(project_name), str(version_name), staging=True)
diff --git a/atr/post/draft.py b/atr/post/draft.py
index c35a3234..af36550b 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -17,7 +17,7 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
 
 import aiofiles.os
 import asfquart.base as base
@@ -28,7 +28,9 @@ import atr.db.interaction as interaction
 import atr.form as form
 import atr.get as get
 import atr.log as log
+import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.models.unsafe as unsafe
 import atr.shared as shared
 import atr.storage as storage
 import atr.util as util
@@ -38,20 +40,27 @@ if TYPE_CHECKING:
     import pathlib
 
 
[email protected]("/draft/reset/<project_name>/<version_name>")
[email protected]()
-async def cache_reset(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse:
-    """Start a new draft revision and switch this release to global caching"""
-    await session.check_access(project_name)
[email protected]
+async def cache_reset(
+    session: web.Committer,
+    _draft_reset: Literal["draft/reset"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    _form: form.Empty,
+) -> web.WerkzeugResponse:
+    """
+    URL: /draft/reset/<project_name>/<version_name>
+    Start a new draft revision and switch this release to global caching.
+    """
     if not session.is_admin:
         raise base.ASFQuartException("Admin access required", errorcode=403)
 
     description = "Empty revision to restart all checks without cache for the 
whole release candidate draft"
     async with storage.write(session) as write:
-        wacp = await write.as_project_committee_participant(project_name)
+        wacp = await write.as_project_committee_participant(str(project_name))
         await wacp.revision.create_revision(
-            project_name,
-            version_name,
+            str(project_name),
+            str(version_name),
             session.uid,
             description=description,
             reset_to_global_cache=True,
@@ -59,106 +68,144 @@ async def cache_reset(session: web.Committer, 
project_name: str, version_name: s
 
     return await session.redirect(
         get.compose.selected,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         success="Release set back to global caching",
     )
 
 
[email protected]("/compose/<project_name>/<version_name>")
[email protected]()
-async def delete(session: web.Committer, project_name: str, version_name: str) 
-> web.WerkzeugResponse:
-    """Delete a candidate draft and all its associated files."""
-
-    await session.check_access(project_name)
-
[email protected]
+async def delete(
+    session: web.Committer,
+    _compose: Literal["compose"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    _form: form.Empty,
+) -> web.WerkzeugResponse:
+    """
+    URL: /compose/<project_name>/<version_name>
+    Delete a candidate draft and all its associated files.
+    """
     # Delete the metadata from the database
     async with storage.write(session) as write:
-        wacp = await write.as_project_committee_participant(project_name)
+        wacp = await write.as_project_committee_participant(str(project_name))
         error = await wacp.release.delete(
-            project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, include_downloads=False
+            str(project_name),
+            str(version_name),
+            phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+            include_downloads=False,
         )
         # Ensure that deletion errors are reported to the user
         if error is not None:
             await quart.flash(f"Error deleting candidate draft: {error}", 
"error")
-            return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+            return await session.redirect(
+                get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+            )
 
     return await session.redirect(get.root.index, success="Candidate draft 
deleted successfully")
 
 
[email protected]("/draft/delete-file/<project_name>/<version_name>")
[email protected](shared.draft.DeleteFileForm)
[email protected]
 async def delete_file(
-    session: web.Committer, delete_file_form: shared.draft.DeleteFileForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _draft_delete_file: Literal["draft/delete-file"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    delete_file_form: shared.draft.DeleteFileForm,
 ) -> web.WerkzeugResponse:
-    """Delete a specific file from the release candidate, creating a new 
revision."""
-    await session.check_access(project_name)
-
+    """
+    URL: /draft/delete-file/<project_name>/<version_name>
+    Delete a specific file from the release candidate, creating a new revision.
+    """
     rel_path_to_delete = delete_file_form.file_path
     if rel_path_to_delete is None:
         await quart.flash("No file path specified", "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(
+            get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+        )
 
     try:
         async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(project_name)
-            metadata_files_deleted = await 
wacp.release.delete_file(project_name, version_name, rel_path_to_delete)
+            wacp = await 
write.as_project_committee_participant(str(project_name))
+            metadata_files_deleted = await wacp.release.delete_file(
+                str(project_name), str(version_name), rel_path_to_delete
+            )
     except Exception as e:
         log.exception("Error deleting file:")
         await quart.flash(f"Error deleting file: {e!s}", "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(
+            get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+        )
 
     success_message = f"File '{rel_path_to_delete.name}' deleted successfully"
     if metadata_files_deleted:
         success_message += f", and {util.plural(metadata_files_deleted, 
'associated metadata file')} deleted"
     return await session.redirect(
-        get.compose.selected, success=success_message, 
project_name=project_name, version_name=version_name
+        get.compose.selected, success=success_message, 
project_name=str(project_name), version_name=str(version_name)
     )
 
 
[email protected]("/draft/hashgen/<project_name>/<version_name>/<path:file_path>")
[email protected]()
-async def hashgen(session: web.Committer, project_name: str, version_name: 
str, file_path: str) -> web.WerkzeugResponse:
-    """Generate an sha512 hash file for a candidate draft file, creating a new 
revision."""
-    await session.check_access(project_name)
-
[email protected]
+async def hashgen(
+    session: web.Committer,
+    _draft_hashgen: Literal["draft/hashgen"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+    empty_form: form.Empty,
+) -> web.WerkzeugResponse:
+    """
+    URL: /draft/hashgen/<project_name>/<version_name>/<file_path>
+    Generate an sha512 hash file for a candidate draft file, creating a new 
revision.
+    """
     rel_path = form.to_relpath(file_path)
     if rel_path is None:
         await quart.flash("Invalid file path", "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(
+            get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+        )
 
     try:
         async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(project_name)
-            await wacp.release.generate_hash_file(project_name, version_name, 
rel_path)
+            wacp = await 
write.as_project_committee_participant(str(project_name))
+            await wacp.release.generate_hash_file(str(project_name), 
str(version_name), rel_path)
 
     except Exception as e:
         log.exception("Error generating hash file:")
         await quart.flash(f"Error generating hash file: {e!s}", "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(
+            get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+        )
 
     return await session.redirect(
         get.compose.selected,
         success="SHA512 file generated successfully",
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
     )
 
 
[email protected]("/draft/recheck/<project_name>/<version_name>")
[email protected]()
-async def recheck(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse:
-    """Start a new draft revision and switch this release to release-local 
caching"""
-    await session.check_access(project_name)
[email protected]
+async def recheck(
+    session: web.Committer,
+    _draft_recheck: Literal["draft/recheck"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    empty_form: form.Empty,
+) -> web.WerkzeugResponse:
+    """
+    URL: /draft/recheck/<project_name>/<version_name>
+    Start a new draft revision and switch this release to release-local 
caching.
+    """
     if not session.is_admin:
         raise base.ASFQuartException("Admin access required", errorcode=403)
 
     description = "Empty revision to restart all checks without cache for the 
whole release candidate draft"
     async with storage.write(session) as write:
-        wacp = await write.as_project_committee_participant(project_name)
+        wacp = await write.as_project_committee_participant(str(project_name))
         await wacp.revision.create_revision(
-            project_name,
-            version_name,
+            str(project_name),
+            str(version_name),
             session.uid,
             description=description,
             set_local_cache=True,
@@ -166,22 +213,31 @@ async def recheck(session: web.Committer, project_name: 
str, version_name: str)
 
     return await session.redirect(
         get.compose.selected,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         success="All checks restarted with release-local cache",
     )
 
 
[email protected]("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>")
[email protected]()
-async def sbomgen(session: web.Committer, project_name: str, version_name: 
str, file_path: str) -> web.WerkzeugResponse:
-    """Generate a CycloneDX SBOM file for a candidate draft file, creating a 
new revision."""
-    await session.check_access(project_name)
-
[email protected]
+async def sbomgen(
+    session: web.Committer,
+    _draft_sbomgen: Literal["draft/sbomgen"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+    empty_form: form.Empty,
+) -> web.WerkzeugResponse:
+    """
+    URL: /draft/sbomgen/<project_name>/<version_name>/<file_path>
+    Generate a CycloneDX SBOM file for a candidate draft file, creating a new 
revision.
+    """
     rel_path = form.to_relpath(file_path)
     if rel_path is None:
         await quart.flash("Invalid file path", "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(
+            get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+        )
 
     # Check that the file is a .tar.gz archive before creating a revision
     if not (
@@ -197,7 +253,7 @@ async def sbomgen(session: web.Committer, project_name: 
str, version_name: str,
     try:
         description = "SBOM generation through web interface"
         async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(project_name)
+            wacp = await 
write.as_project_committee_participant(str(project_name))
 
             async def modify(path: pathlib.Path, old_rev: sql.Revision | None) 
-> None:
                 path_in_new_revision = path / rel_path
@@ -219,24 +275,30 @@ async def sbomgen(session: web.Committer, project_name: 
str, version_name: str,
 
                 # Create and queue the task, using paths within the new 
revision
                 sbom_task = await wacp.sbom.generate_cyclonedx(
-                    project_name, version_name, old_rev.number, 
path_in_new_revision, sbom_path_in_new_revision
+                    str(project_name),
+                    str(version_name),
+                    old_rev.number,
+                    path_in_new_revision,
+                    sbom_path_in_new_revision,
                 )
                 success = await interaction.wait_for_task(sbom_task)
                 if not success:
                     raise web.FlashError("Internal error: SBOM generation 
timed out")
 
             await wacp.revision.create_revision(
-                project_name, version_name, session.uid, 
description=description, modify=modify
+                str(project_name), str(version_name), session.uid, 
description=description, modify=modify
             )
 
     except Exception as e:
         log.exception("Error generating SBOM:")
         await quart.flash(f"Error generating SBOM: {e!s}", "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(
+            get.compose.selected, project_name=str(project_name), 
version_name=str(version_name)
+        )
 
     return await session.redirect(
         get.compose.selected,
         success=f"SBOM generated for {rel_path.name}",
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
     )
diff --git a/atr/post/finish.py b/atr/post/finish.py
index 7408891d..fa438c87 100644
--- a/atr/post/finish.py
+++ b/atr/post/finish.py
@@ -15,31 +15,40 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import quart
 
 import atr.blueprints.post as post
 import atr.log as log
+import atr.models.safe as safe
 import atr.shared as shared
 import atr.storage as storage
 import atr.util as util
 import atr.web as web
 
 
[email protected]("/finish/<project_name>/<version_name>")
[email protected](shared.finish.FinishForm)
[email protected]
 async def selected(
-    session: web.Committer, finish_form: shared.finish.FinishForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _finish: Literal["finish"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    finish_form: shared.finish.FinishForm,
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+    """
+    URL: /finish/<project_name>/<version_name>
+    """
     wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
-    respond = _respond_helper(session, project_name, version_name, wants_json)
+    respond = _respond_helper(session, str(project_name), str(version_name), 
wants_json)
 
     match finish_form:
         case shared.finish.DeleteEmptyDirectoryForm() as delete_form:
-            return await _delete_empty_directory(delete_form, session, 
project_name, version_name, respond)
+            return await _delete_empty_directory(delete_form, session, 
str(project_name), str(version_name), respond)
         case shared.finish.MoveFileForm() as move_form:
-            return await _move_file_to_revision(move_form, session, 
project_name, version_name, respond)
+            return await _move_file_to_revision(move_form, session, 
str(project_name), str(version_name), respond)
         case shared.finish.RemoveRCTagsForm():
-            return await _remove_rc_tags(session, project_name, version_name, 
respond)
+            return await _remove_rc_tags(session, str(project_name), 
str(version_name), respond)
 
 
 async def _delete_empty_directory(
diff --git a/atr/post/ignores.py b/atr/post/ignores.py
index 974dbb2d..853942cb 100644
--- a/atr/post/ignores.py
+++ b/atr/post/ignores.py
@@ -16,28 +16,36 @@
 # under the License.
 
 
+from typing import Literal
+
 import atr.blueprints.post as post
 import atr.get as get
+import atr.models.safe as safe
 import atr.shared as shared
 import atr.storage as storage
 import atr.web as web
 
 
[email protected]("/ignores/<project_name>")
[email protected](shared.ignores.IgnoreForm)
[email protected]
 async def ignores(
-    session: web.Committer, ignore_form: shared.ignores.IgnoreForm, 
project_name: str
+    session: web.Committer,
+    _ignores: Literal["ignores"],
+    project_name: safe.ProjectName,
+    ignore_form: shared.ignores.IgnoreForm,
 ) -> web.WerkzeugResponse:
-    """Handle forms on the ignores page."""
+    """
+    URL: /ignores/<project_name>
+    Handle forms on the ignores page.
+    """
     match ignore_form:
         case shared.ignores.AddIgnoreForm() as add_form:
-            return await _add_ignore(session, add_form, project_name)
+            return await _add_ignore(session, add_form, str(project_name))
 
         case shared.ignores.DeleteIgnoreForm() as delete_form:
-            return await _delete_ignore(session, delete_form, project_name)
+            return await _delete_ignore(session, delete_form, 
str(project_name))
 
         case shared.ignores.UpdateIgnoreForm() as update_form:
-            return await _update_ignore(session, update_form, project_name)
+            return await _update_ignore(session, update_form, 
str(project_name))
 
 
 async def _add_ignore(
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 82b6aff2..1e651914 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -17,7 +17,7 @@
 
 
 import asyncio
-from typing import Final
+from typing import Final, Literal
 
 import aiohttp
 import asfquart.base as base
@@ -25,9 +25,11 @@ import quart
 
 import atr.blueprints.post as post
 import atr.db as db
+import atr.form as form
 import atr.get as get
 import atr.htm as htm
 import atr.log as log
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
@@ -39,10 +41,16 @@ import atr.web as web
 _KEYS_BASE_URL: Final[str] = "https://downloads.apache.org";
 
 
[email protected]("/keys/add")
[email protected](shared.keys.AddOpenPGPKeyForm)
-async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenPGPKeyForm) -> web.WerkzeugResponse:
-    """Add a new public signing key to the user's account."""
[email protected]
+async def add(
+    session: web.Committer,
+    _keys_add: Literal["keys/add"],
+    add_openpgp_key_form: shared.keys.AddOpenPGPKeyForm,
+) -> web.WerkzeugResponse:
+    """
+    URL: /keys/add
+    Add a new public signing key to the user's account.
+    """
     try:
         key_text = add_openpgp_key_form.public_key
         selected_committee_names = add_openpgp_key_form.selected_committees
@@ -81,12 +89,17 @@ async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenP
     return await session.redirect(get.keys.keys)
 
 
[email protected]("/keys/details/<fingerprint>")
[email protected](shared.keys.UpdateKeyCommitteesForm)
[email protected]
 async def details(
-    session: web.Committer, update_form: shared.keys.UpdateKeyCommitteesForm, 
fingerprint: str
+    session: web.Committer,
+    _keys_details: Literal["keys/details"],
+    fingerprint: str,
+    update_form: shared.keys.UpdateKeyCommitteesForm,
 ) -> web.WerkzeugResponse:
-    """Update committee associations for an OpenPGP key."""
+    """
+    URL: /keys/details/<fingerprint>
+    Update committee associations for an OpenPGP key.
+    """
     fingerprint = fingerprint.lower()
 
     try:
@@ -125,14 +138,20 @@ async def details(
     return await session.redirect(get.keys.details, fingerprint=fingerprint)
 
 
[email protected]("/keys/import/<project_name>/<version_name>")
[email protected]()
[email protected]
 async def import_selected_revision(
-    session: web.Committer, project_name: str, version_name: str
+    session: web.Committer,
+    _keys_import: Literal["keys/import"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    _form: form.Empty,
 ) -> web.WerkzeugResponse:
+    """
+    URL: /keys/import/<project_name>/<version_name>
+    """
     async with storage.write() as write:
-        wacm = await write.as_project_committee_member(project_name)
-        outcomes: outcome.List[types.Key] = await 
wacm.keys.import_keys_file(project_name, version_name)
+        wacm = await write.as_project_committee_member(str(project_name))
+        outcomes: outcome.List[types.Key] = await 
wacm.keys.import_keys_file(str(project_name), str(version_name))
 
     message = f"Uploaded {util.plural(outcomes.result_count, 'key')}"
     if outcomes.error_count > 0:
@@ -140,15 +159,21 @@ async def import_selected_revision(
     return await session.redirect(
         get.compose.selected,
         success=message,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
     )
 
 
[email protected]("/keys")
[email protected](shared.keys.KeysForm)
-async def keys(session: web.Committer, keys_form: shared.keys.KeysForm) -> 
web.WerkzeugResponse:
-    """Handle forms on the keys management page."""
[email protected]
+async def keys(
+    session: web.Committer,
+    _keys: Literal["keys"],
+    keys_form: shared.keys.KeysForm,
+) -> web.WerkzeugResponse:
+    """
+    URL: /keys
+    Handle forms on the keys management page.
+    """
     match keys_form:
         case shared.keys.DeleteOpenPGPKeyForm() as delete_openpgp_form:
             return await _delete_openpgp_key(session, delete_openpgp_form)
@@ -160,10 +185,16 @@ async def keys(session: web.Committer, keys_form: 
shared.keys.KeysForm) -> web.W
             return await _update_committee_keys(session, update_committee_form)
 
 
[email protected]("/keys/ssh/add")
[email protected](shared.keys.AddSSHKeyForm)
-async def ssh_add(session: web.Committer, add_ssh_key_form: 
shared.keys.AddSSHKeyForm) -> web.WerkzeugResponse:
-    """Add a new SSH key to the user's account."""
[email protected]
+async def ssh_add(
+    session: web.Committer,
+    _keys_ssh_add: Literal["keys/ssh/add"],
+    add_ssh_key_form: shared.keys.AddSSHKeyForm,
+) -> web.WerkzeugResponse:
+    """
+    URL: /keys/ssh/add
+    Add a new SSH key to the user's account.
+    """
     try:
         async with storage.write(session) as write:
             wafc = write.as_foundation_committer()
@@ -179,10 +210,16 @@ async def ssh_add(session: web.Committer, 
add_ssh_key_form: shared.keys.AddSSHKe
     return await session.redirect(get.keys.keys)
 
 
[email protected]("/keys/upload")
[email protected](shared.keys.UploadKeysForm)
-async def upload(session: web.Committer, upload_form: 
shared.keys.UploadKeysForm) -> str:
-    """Upload or fetch a KEYS file containing multiple OpenPGP keys."""
[email protected]
+async def upload(
+    session: web.Committer,
+    _keys_upload: Literal["keys/upload"],
+    upload_form: shared.keys.UploadKeysForm,
+) -> str:
+    """
+    URL: /keys/upload
+    Upload or fetch a KEYS file containing multiple OpenPGP keys.
+    """
     match upload_form:
         case shared.keys.UploadFileForm() as upload_file_form:
             return await _upload_file_keys(upload_file_form)
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 4beb8f59..c3c34612 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -15,10 +15,14 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import atr.blueprints.post as post
 import atr.db as db
 import atr.db.interaction as interaction
+import atr.form as form
 import atr.get as get
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
@@ -26,19 +30,21 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/manual/resolve/<project_name>/<version_name>")
[email protected](shared.manual.ResolveVoteForm)
[email protected]
 async def resolve_selected(
     session: web.Committer,
+    _manual_resolve: Literal["manual/resolve"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
     resolve_vote_form: shared.manual.ResolveVoteForm,
-    project_name: str,
-    version_name: str,
 ) -> web.WerkzeugResponse | str:
-    """Post the manual vote resolution page."""
-    await session.check_access(project_name)
+    """
+    URL: /manual/resolve/<project_name>/<version_name>
+    Post the manual vote resolution page.
+    """
     release = await session.release(
-        project_name,
-        version_name,
+        str(project_name),
+        str(version_name),
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,
         with_release_policy=True,
         with_project_release_policy=True,
@@ -51,8 +57,8 @@ async def resolve_selected(
     except RuntimeError as e:
         return await session.redirect(
             get.manual.resolve_selected,
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
             error=str(e),
         )
 
@@ -64,31 +70,40 @@ async def resolve_selected(
             vote_result = "failed"
             destination = get.compose.selected
 
-    async with storage.write_as_project_committee_member(project_name) as wacm:
-        success_message = await wacm.vote.resolve_manually(project_name, 
release, vote_result)
+    async with storage.write_as_project_committee_member(str(project_name)) as 
wacm:
+        success_message = await wacm.vote.resolve_manually(str(project_name), 
release, vote_result)
 
     return await session.redirect(
-        destination, project_name=project_name, version_name=version_name, 
success=success_message
+        destination,
+        project_name=str(project_name),
+        version_name=str(version_name),
+        success=success_message,
     )
 
 
[email protected]("/manual/start/<project_name>/<version_name>/<revision>")
[email protected]()
[email protected]
 async def start_selected_revision(
-    session: web.Committer, project_name: str, version_name: str, revision: str
+    session: web.Committer,
+    _manual_start: Literal["manual/start"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    revision: str,
+    _form: form.Empty,
 ) -> web.WerkzeugResponse | str:
-    await session.check_access(project_name)
+    """
+    URL: /manual/start/<project_name>/<version_name>/<revision>
+    """
 
     async with db.session() as data:
         match await interaction.release_ready_for_vote(
-            session, project_name, version_name, revision, data, 
manual_vote=True
+            session, str(project_name), str(version_name), revision, data, 
manual_vote=True
         ):
             case str() as error:
                 return await session.redirect(
                     get.vote.selected,
                     error=error,
-                    project_name=project_name,
-                    version_name=version_name,
+                    project_name=str(project_name),
+                    version_name=str(version_name),
                 )
             case (release, _committee):
                 pass
@@ -101,15 +116,15 @@ async def start_selected_revision(
             return await session.redirect(
                 get.vote.selected,
                 error=error,
-                project_name=project_name,
-                version_name=version_name,
+                project_name=str(project_name),
+                version_name=str(version_name),
             )
 
         return await session.redirect(
             get.vote.selected,
             success="The manual vote process has been started.",
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
         )
 
 
diff --git a/atr/post/projects.py b/atr/post/projects.py
index b3007626..9a2295d7 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,6 +17,8 @@
 
 from __future__ import annotations
 
+from typing import Literal
+
 import asfquart.base as base
 import quart
 
@@ -29,11 +31,16 @@ import atr.storage as storage
 import atr.web as web
 
 
[email protected]("/project/add/<committee_name>")
[email protected](shared.projects.AddProjectForm)
[email protected]
 async def add_project(
-    session: web.Committer, project_form: shared.projects.AddProjectForm, 
committee_name: str
+    session: web.Committer,
+    _project_add: Literal["project/add"],
+    committee_name: str,
+    project_form: shared.projects.AddProjectForm,
 ) -> web.WerkzeugResponse:
+    """
+    URL: /project/add/<committee_name>
+    """
     display_name = project_form.display_name
     label = project_form.label
 
@@ -51,12 +58,16 @@ async def add_project(
     )
 
 
[email protected]("/project/delete")
[email protected](shared.projects.DeleteSelectedProject)
[email protected]
 async def delete(
-    session: web.Committer, delete_selected_project_form: 
shared.projects.DeleteSelectedProject
+    session: web.Committer,
+    _project_delete: Literal["project/delete"],
+    delete_selected_project_form: shared.projects.DeleteSelectedProject,
 ) -> web.WerkzeugResponse:
-    """Delete a project created by the user."""
+    """
+    URL: /project/delete
+    Delete a project created by the user.
+    """
     project_name = delete_selected_project_form.project_name
 
     async with storage.write(session) as write:
@@ -71,11 +82,16 @@ async def delete(
     return await session.redirect(get.projects.projects, success=f"Project 
'{project_name}' deleted successfully.")
 
 
[email protected]("/projects/<name>")
[email protected](shared.projects.ProjectViewForm)
[email protected]
 async def view(
-    session: web.Committer, project_form: shared.projects.ProjectViewForm, 
name: str
+    session: web.Committer,
+    _projects: Literal["projects"],
+    name: str,
+    project_form: shared.projects.ProjectViewForm,
 ) -> web.WerkzeugResponse:
+    """
+    URL: /projects/<name>
+    """
     match project_form:
         case shared.projects.AddCategoryForm() as add_category_form:
             return await _process_add_category(session, add_category_form)
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 9df73d71..a631f6f5 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -15,12 +15,15 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import quart
 
 import atr.blueprints.post as post
 import atr.db.interaction as interaction
 import atr.form
 import atr.get as get
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
@@ -30,19 +33,23 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/resolve/<project_name>/<version_name>")
[email protected](shared.resolve.ResolveForm)
[email protected]
 async def selected(
-    session: web.Committer, resolve_form: shared.resolve.ResolveForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _resolve: Literal["resolve"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    resolve_form: shared.resolve.ResolveForm,
 ) -> web.WerkzeugResponse | str:
-    await session.check_access(project_name)
-
+    """
+    URL: /resolve/<project_name>/<version_name>
+    """
     match resolve_form:
         case shared.resolve.SubmitForm() as submit_form:
-            return await _submit(session, submit_form, project_name, 
version_name)
+            return await _submit(session, submit_form, str(project_name), 
str(version_name))
 
         case shared.resolve.TabulateForm():
-            return await _tabulate(session, project_name, version_name)
+            return await _tabulate(session, str(project_name), 
str(version_name))
 
 
 async def _submit(
diff --git a/atr/post/revisions.py b/atr/post/revisions.py
index 5906200c..1c994030 100644
--- a/atr/post/revisions.py
+++ b/atr/post/revisions.py
@@ -14,30 +14,36 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Literal
 
 import asfquart.base as base
 
 import atr.blueprints.post as post
 import atr.db as db
 import atr.get as get
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
 import atr.web as web
 
 
[email protected]("/revisions/<project_name>/<version_name>")
[email protected](shared.revisions.RevisionForm)
[email protected]
 async def selected_post(
-    session: web.Committer, revision_form: shared.revisions.RevisionForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _revisions: Literal["revisions"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    revision_form: shared.revisions.RevisionForm,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project_name)
-
+    """
+    URL: /revisions/<project_name>/<version_name>
+    """
     match revision_form:
         case shared.revisions.SetRevisionForm():
-            return await _set_revision(session, revision_form, project_name, 
version_name)
+            return await _set_revision(session, revision_form, 
str(project_name), str(version_name))
         case shared.revisions.SetTagForm():
-            return await _set_tag(session, revision_form, project_name, 
version_name)
+            return await _set_tag(session, revision_form, str(project_name), 
str(version_name))
 
 
 async def _set_revision(
diff --git a/atr/post/sbom.py b/atr/post/sbom.py
index 564f0537..4f614b0e 100644
--- a/atr/post/sbom.py
+++ b/atr/post/sbom.py
@@ -17,7 +17,7 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
 
 import asfquart.base as base
 import quart
@@ -27,6 +27,8 @@ import atr.db as db
 import atr.form as form
 import atr.get as get
 import atr.log as log
+import atr.models.safe as safe
+import atr.models.unsafe as unsafe
 import atr.shared as shared
 import atr.storage as storage
 import atr.util as util
@@ -36,12 +38,18 @@ if TYPE_CHECKING:
     import pathlib
 
 
[email protected]("/sbom/report/<project>/<version>/<path:file_path>")
[email protected](shared.sbom.SBOMForm)
[email protected]
 async def report(
-    session: web.Committer, sbom_form: shared.sbom.SBOMForm, project: str, 
version: str, file_path: str
+    session: web.Committer,
+    _sbom_report: Literal["sbom/report"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+    sbom_form: shared.sbom.SBOMForm,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project)
+    """
+    URL: /sbom/report/<project_name>/<version_name>/<path:file_path>
+    """
 
     validated_path = form.to_relpath(file_path)
     if validated_path is None:
@@ -49,10 +57,10 @@ async def report(
 
     match sbom_form:
         case shared.sbom.AugmentSBOMForm():
-            return await _augment(session, project, version, validated_path)
+            return await _augment(session, str(project_name), 
str(version_name), validated_path)
 
         case shared.sbom.ScanSBOMForm():
-            return await _scan(session, project, version, validated_path)
+            return await _scan(session, str(project_name), str(version_name), 
validated_path)
 
 
 async def _augment(
diff --git a/atr/post/start.py b/atr/post/start.py
index ea3e52c0..fea9a2f2 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -14,38 +14,44 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Literal
 
 import asfquart.base as base
 import quart
 
 import atr.blueprints.post as post
 import atr.get as get
+import atr.models.safe as safe
 import atr.shared as shared
 import atr.storage as storage
 import atr.web as web
 
 
[email protected]("/start/<project_name>")
[email protected](shared.start.StartReleaseForm)
[email protected]
 async def selected(
-    session: web.Committer, start_release_form: shared.start.StartReleaseForm, 
project_name: str
+    session: web.Committer,
+    _start: Literal["start"],
+    project_name: safe.ProjectName,
+    start_release_form: shared.start.StartReleaseForm,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project_name)
+    """
+    URL: /start/<project_name>
+    """
 
     try:
         async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(project_name)
+            wacp = await 
write.as_project_committee_participant(str(project_name))
             new_release, _project = await wacp.release.start(
-                project_name,
+                str(project_name),
                 start_release_form.version_name,
             )
 
         return await session.redirect(
             get.compose.selected,
-            project_name=project_name,
+            project_name=str(project_name),
             version_name=new_release.version,
             success="Release candidate draft created successfully",
         )
     except (web.FlashError, base.ASFQuartException) as e:
         await quart.flash(str(e), "error")
-        return await session.redirect(get.start.selected, 
project_name=project_name)
+        return await session.redirect(get.start.selected, 
project_name=str(project_name))
diff --git a/atr/post/test.py b/atr/post/test.py
index b2945df6..4fd4ee97 100644
--- a/atr/post/test.py
+++ b/atr/post/test.py
@@ -14,29 +14,39 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Literal
 
 import quart
 
 import atr.blueprints.post as post
+import atr.form as form
 import atr.get as get
 import atr.log as log
 import atr.shared as shared
 import atr.web as web
 
 
[email protected]("/test/empty")
[email protected]()
-async def test_empty(session: web.Public) -> web.WerkzeugResponse:
[email protected]
+async def test_empty(
+    session: web.Public, _test_empty: Literal["test/empty"], _form: form.Empty
+) -> web.WerkzeugResponse:
+    """
+    URL: /test/empty
+    """
     msg = "Empty form submitted successfully"
     log.info(msg)
     await quart.flash(msg, "success")
     return await web.redirect(get.test.test_empty)
 
 
[email protected]("/test/multiple")
[email protected](shared.test.MultipleForm)
-async def test_multiple(session: web.Public, form: shared.test.MultipleForm) 
-> web.WerkzeugResponse:
-    match form:
[email protected]
+async def test_multiple(
+    session: web.Public, _test_multiple: Literal["test/multiple"], 
multiple_form: shared.test.MultipleForm
+) -> web.WerkzeugResponse:
+    """
+    URL: /test/multiple
+    """
+    match multiple_form:
         case shared.test.AppleForm() as apple:
             msg = f"Apple order received: variety={apple.variety}, 
quantity={apple.quantity}, organic={apple.organic}"
             log.info(msg)
@@ -50,24 +60,28 @@ async def test_multiple(session: web.Public, form: 
shared.test.MultipleForm) ->
     return await web.redirect(get.test.test_multiple)
 
 
[email protected]("/test/single")
[email protected](shared.test.SingleForm)
-async def test_single(session: web.Public, form: shared.test.SingleForm) -> 
web.WerkzeugResponse:
-    file_names = [f.filename for f in form.files] if form.files else []
-    compatibility_names = [f.value for f in form.compatibility] if 
form.compatibility else []
-    if (form.message == "Forbidden message!") and (session is not None):
[email protected]
+async def test_single(
+    session: web.Public, _test_single: Literal["test/single"], single_form: 
shared.test.SingleForm
+) -> web.WerkzeugResponse:
+    """
+    URL: /test/single
+    """
+    file_names = [f.filename for f in single_form.files] if single_form.files 
else []
+    compatibility_names = [f.value for f in single_form.compatibility] if 
single_form.compatibility else []
+    if (single_form.message == "Forbidden message!") and (session is not None):
         return await session.form_error(
             "message",
             "You are not permitted to submit the forbidden message",
         )
     msg = (
         f"Single form received:"
-        f" name={form.name},"
-        f" email={form.email},"
-        f" message={form.message},"
+        f" name={single_form.name},"
+        f" email={single_form.email},"
+        f" message={single_form.message},"
         f" files={file_names},"
         f" compatibility={compatibility_names},"
-        f" vote={form.vote}"
+        f" vote={single_form.vote}"
     )
     log.info(msg)
     await quart.flash(msg, "success")
diff --git a/atr/post/tokens.py b/atr/post/tokens.py
index 6d55f4ae..157b8023 100644
--- a/atr/post/tokens.py
+++ b/atr/post/tokens.py
@@ -18,12 +18,13 @@
 import datetime
 import hashlib
 import secrets
-from typing import Final
+from typing import Final, Literal
 
 import quart
 import quart_rate_limiter as rate_limiter
 
 import atr.blueprints.post as post
+import atr.form as form
 import atr.get as get
 import atr.htm as htm
 import atr.jwtoken as jwtoken
@@ -34,18 +35,24 @@ import atr.web as web
 _EXPIRY_DAYS: Final[int] = 180
 
 
[email protected]("/tokens/jwt")
[email protected]
 @rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
[email protected]()
-async def jwt_post(session: web.Committer) -> web.QuartResponse:
+async def jwt_post(session: web.Committer, _tokens_jwt: Literal["tokens/jwt"], 
_form: form.Empty) -> web.QuartResponse:
+    """
+    URL: /tokens/jwt
+    """
     jwt_token = jwtoken.issue(session.uid)
     return web.TextResponse(jwt_token)
 
 
[email protected]("/tokens")
[email protected]
 @rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
[email protected](shared.tokens.TokenForm)
-async def tokens(session: web.Committer, token_form: shared.tokens.TokenForm) 
-> web.WerkzeugResponse:
+async def tokens(
+    session: web.Committer, _tokens: Literal["tokens"], token_form: 
shared.tokens.TokenForm
+) -> web.WerkzeugResponse:
+    """
+    URL: /tokens
+    """
     match token_form:
         case shared.tokens.AddTokenForm() as add_form:
             return await _add_token(session, add_form)
diff --git a/atr/post/upload.py b/atr/post/upload.py
index 7b4f9f50..45526f00 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -18,7 +18,7 @@
 import asyncio
 import json
 import pathlib
-from typing import Final
+from typing import Final, Literal
 
 import aiofiles
 import aiofiles.os
@@ -31,6 +31,7 @@ import atr.db as db
 import atr.form as form
 import atr.get as get
 import atr.log as log
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.paths as paths
 import atr.shared as shared
@@ -42,11 +43,17 @@ import atr.web as web
 _SVN_BASE_URL: Final[str] = "https://dist.apache.org/repos/dist";
 
 
[email protected]("/upload/finalise/<project_name>/<version_name>/<upload_session>")
[email protected]
 async def finalise(
-    session: web.Committer, project_name: str, version_name: str, 
upload_session: str
+    session: web.Committer,
+    _upload_finalise: Literal["upload/finalise"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    upload_session: str,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project_name)
+    """
+    URL: /upload/finalise/<project_name>/<version_name>/<upload_session>
+    """
 
     try:
         staging_dir = paths.get_upload_staging_dir(upload_session)
@@ -67,7 +74,7 @@ async def finalise(
 
     try:
         async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(project_name)
+            wacp = await 
write.as_project_committee_participant(str(project_name))
             number_of_files = len(staged_files)
             description = f"Upload of {util.plural(number_of_files, 'file')} 
through web interface"
 
@@ -78,7 +85,7 @@ async def finalise(
                     await aioshutil.move(str(src), str(dst))
 
             await wacp.revision.create_revision(
-                project_name, version_name, session.uid, 
description=description, modify=modify
+                str(project_name), str(version_name), session.uid, 
description=description, modify=modify
             )
 
         await aioshutil.rmtree(staging_dir)
@@ -86,42 +93,53 @@ async def finalise(
         return await session.redirect(
             get.compose.selected,
             success=f"{util.plural(number_of_files, 'file')} added 
successfully",
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
         )
     except types.FailedError as e:
         await aioshutil.rmtree(staging_dir)
         await quart.flash(str(e), "error")
         return await session.redirect(
             get.upload.selected,
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
         )
     except Exception as e:
         log.exception("Error finalising upload:")
         return _json_error(f"Error finalising upload: {e!s}", 500)
 
 
[email protected]("/upload/<project_name>/<version_name>")
[email protected](shared.upload.UploadForm)
[email protected]
 async def selected(
-    session: web.Committer, upload_form: shared.upload.UploadForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _upload: Literal["upload"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    upload_form: shared.upload.UploadForm,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project_name)
+    """
+    URL: /upload/<project_name>/<version_name>
+    """
 
     match upload_form:
         case shared.upload.AddFilesForm() as add_form:
-            return await _add_files(session, add_form, project_name, 
version_name)
+            return await _add_files(session, add_form, str(project_name), 
str(version_name))
 
         case shared.upload.SvnImportForm() as svn_form:
-            return await _svn_import(session, svn_form, project_name, 
version_name)
+            return await _svn_import(session, svn_form, str(project_name), 
str(version_name))
 
 
[email protected]("/upload/stage/<project_name>/<version_name>/<upload_session>")
[email protected]
 async def stage(
-    session: web.Committer, project_name: str, version_name: str, 
upload_session: str
+    session: web.Committer,
+    _upload_stage: Literal["upload/stage"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    upload_session: str,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project_name)
+    """
+    URL: /upload/stage/<project_name>/<version_name>/<upload_session>
+    """
 
     try:
         staging_dir = paths.get_upload_staging_dir(upload_session)
diff --git a/atr/post/user.py b/atr/post/user.py
index a414c10d..06ec280a 100644
--- a/atr/post/user.py
+++ b/atr/post/user.py
@@ -14,6 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Literal
 
 import quart
 
@@ -24,9 +25,13 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/user/cache")
[email protected](shared.user.UserCacheForm)
-async def session_post(session: web.Committer, user_cache_form: 
shared.user.UserCacheForm) -> web.WerkzeugResponse:
[email protected]
+async def session_post(
+    session: web.Committer, _user_cache: Literal["user/cache"], 
user_cache_form: shared.user.UserCacheForm
+) -> web.WerkzeugResponse:
+    """
+    URL: /user/cache
+    """
     match user_cache_form:
         case shared.user.CacheUserForm():
             await _cache_session(session)
diff --git a/atr/post/vote.py b/atr/post/vote.py
index bed25c79..894fbc69 100644
--- a/atr/post/vote.py
+++ b/atr/post/vote.py
@@ -14,11 +14,13 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Literal
 
 import quart
 
 import atr.blueprints.post as post
 import atr.get as get
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
@@ -26,14 +28,19 @@ import atr.user as user
 import atr.web as web
 
 
[email protected]("/vote/<project_name>/<version_name>")
[email protected](shared.vote.CastVoteForm)
[email protected]
 async def selected_post(
-    session: web.Committer, cast_vote_form: shared.vote.CastVoteForm, 
project_name: str, version_name: str
+    session: web.Committer,
+    _vote: Literal["vote"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    cast_vote_form: shared.vote.CastVoteForm,
 ) -> web.WerkzeugResponse:
-    await session.check_access(project_name)
+    """
+    URL: /vote/<project_name>/<version_name>
+    """
 
-    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE)
+    release = await session.release(str(project_name), str(version_name), 
phase=sql.ReleasePhase.RELEASE_CANDIDATE)
 
     if release.committee is None:
         raise ValueError("Release has no committee")
@@ -51,8 +58,8 @@ async def selected_post(
 
     if error_message:
         await quart.flash(error_message, "error")
-        return await session.redirect(get.vote.selected, 
project_name=project_name, version_name=version_name)
+        return await session.redirect(get.vote.selected, 
project_name=str(project_name), version_name=str(version_name))
 
     success_message = f"Sending your vote to {email_recipient}."
     await quart.flash(success_message, "success")
-    return await session.redirect(get.vote.selected, 
project_name=project_name, version_name=version_name)
+    return await session.redirect(get.vote.selected, 
project_name=str(project_name), version_name=str(version_name))
diff --git a/atr/post/voting.py b/atr/post/voting.py
index a5430793..26a701b4 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -17,6 +17,8 @@
 
 from __future__ import annotations
 
+from typing import Literal
+
 import atr.blueprints.post as post
 import atr.construct as construct
 import atr.db as db
@@ -24,6 +26,7 @@ import atr.db.interaction as interaction
 import atr.form as form
 import atr.get as get
 import atr.log as log
+import atr.models.safe as safe
 import atr.shared as shared
 import atr.storage as storage
 import atr.util as util
@@ -34,25 +37,27 @@ class BodyPreviewForm(form.Form):
     vote_duration: form.Int = form.label("Vote duration")
 
 
[email protected]("/voting/body/preview/<project_name>/<version_name>/<revision_number>")
[email protected](BodyPreviewForm)
[email protected]
 async def body_preview(
     session: web.Committer,
-    preview_form: BodyPreviewForm,
-    project_name: str,
-    version_name: str,
+    _voting_body_preview: Literal["voting/body/preview"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
     revision_number: str,
+    preview_form: BodyPreviewForm,
 ) -> web.QuartResponse:
-    await session.check_access(project_name)
+    """
+    URL: /voting/body/preview/<project_name>/<version_name>/<revision_number>
+    """
 
-    default_subject_template = await 
construct.start_vote_subject_default(project_name)
-    default_body_template = await construct.start_vote_default(project_name)
+    default_subject_template = await 
construct.start_vote_subject_default(str(project_name))
+    default_body_template = await 
construct.start_vote_default(str(project_name))
 
     options = construct.StartVoteOptions(
         asfuid=session.uid,
         fullname=session.fullname,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         revision_number=revision_number,
         vote_duration=preview_form.vote_duration,
     )
@@ -61,27 +66,29 @@ async def body_preview(
     return web.TextResponse(body)
 
 
[email protected]("/voting/<project_name>/<version_name>/<revision>")
[email protected](shared.voting.StartVotingForm)
[email protected]
 async def selected_revision(
     session: web.Committer,
-    start_voting_form: shared.voting.StartVotingForm,
-    project_name: str,
-    version_name: str,
+    _voting: Literal["voting"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
     revision: str,
+    start_voting_form: shared.voting.StartVotingForm,
 ) -> web.WerkzeugResponse | str:
-    await session.check_access(project_name)
+    """
+    URL: /voting/<project_name>/<version_name>/<revision>
+    """
 
     async with db.session() as data:
         match await interaction.release_ready_for_vote(
-            session, project_name, version_name, revision, data, 
manual_vote=False
+            session, str(project_name), str(version_name), revision, data, 
manual_vote=False
         ):
             case str() as error:
                 return await session.redirect(
                     get.compose.selected,
                     error=error,
-                    project_name=project_name,
-                    version_name=version_name,
+                    project_name=str(project_name),
+                    version_name=str(version_name),
                     revision=revision,
                 )
             case (release, committee):
@@ -94,7 +101,7 @@ async def selected_revision(
                 f"Invalid mailing list selection: 
{start_voting_form.mailing_list}",
             )
 
-        subject_template = await 
construct.start_vote_subject_default(project_name)
+        subject_template = await 
construct.start_vote_subject_default(str(project_name))
         current_hash = construct.template_hash(subject_template)
         if current_hash != start_voting_form.subject_template_hash:
             return await session.form_error(
@@ -106,8 +113,8 @@ async def selected_revision(
         options = construct.StartVoteOptions(
             asfuid=session.uid,
             fullname=session.fullname,
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
             revision_number=revision,
             vote_duration=start_voting_form.vote_duration,
         )
@@ -116,8 +123,8 @@ async def selected_revision(
         async with storage.write_as_committee_participant(committee.name) as 
wacp:
             _task = await wacp.vote.start(
                 start_voting_form.mailing_list,
-                project_name,
-                version_name,
+                str(project_name),
+                str(version_name),
                 revision,
                 start_voting_form.vote_duration,
                 subject,
@@ -133,6 +140,6 @@ async def selected_revision(
         return await session.redirect(
             get.vote.selected,
             success=f"The vote announcement email will soon be sent to 
{start_voting_form.mailing_list}.",
-            project_name=project_name,
-            version_name=version_name,
+            project_name=str(project_name),
+            version_name=str(version_name),
         )


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to