feat(l4d2-web): server identity by id, name as display label

Host-side identifier (systemd unit name and /var/lib/left4me dirs) is now
str(server.id), centralized in services/server_identity.server_unit_name.
Server.name becomes a free-form display label, required and unique per
user (was [a-z0-9_-]{1,64} and globally unique).

Migration 0006 swaps the old global UNIQUE(name) for UNIQUE(user_id, name).
Web routes already keyed on id; templates only used name for display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 19:22:09 +02:00
parent 0d906605e9
commit 1166e13e44
No known key found for this signature in database
12 changed files with 246 additions and 37 deletions

View file

@ -0,0 +1,55 @@
# Server ID as Host Identifier Design
**Goal:** Decouple the user-facing server label from the host-side identifier. The systemd unit name and on-disk paths become functions of `Server.id`; `Server.name` becomes a free-form display label.
**Approval status:** User-approved 2026-05-08.
## Context
`Server.name` was doing two unrelated jobs. It was the human label rendered in the UI *and* the literal string fed to `l4d2ctl`, which became the systemd unit instance (`left4me-server@<name>.service`) and the directories under `/var/lib/left4me/{instances,runtime}/<name>/`. To stay safe as a unit-template parameter and a path component, the name was forced through `[a-z0-9][a-z0-9_-]{0,63}` and held globally unique. The cost was a UI that demanded machine-friendly slugs, no rename support, and an awkward divergence from overlays — which already separate identity (`id`) from label (`name`).
This change moves servers onto the same model as overlays. Web URLs already key on `id` (`/servers/<int:server_id>`), so the change is mostly local: pick an id-derived host identifier, pass that everywhere `server.name` was passed, and relax the `name` constraints.
## Locked Decisions
1. **Host-side identifier = plain numeric id.** `left4me-server@42.service`, `/var/lib/left4me/instances/42/`, `/var/lib/left4me/runtime/42/`. The host CLI's `validate_instance_name` regex (`[a-z0-9][a-z0-9_-]{0,63}`), the systemctl helper's argument check (`[A-Za-z0-9_.-]`), and the unit template (`%i`) all already accept digit-only strings — no host-side change.
2. **Name = free-form display label, unique per user, required (≤128 chars).** Whitespace is stripped on save. Two users can both have a server named "Practice"; one user cannot.
3. **No data preservation.** Dev-only deploy. Existing servers on the test host are not migrated; their old `left4me-server@<old-name>.service` units and `<old-name>/` directories become orphans and are cleaned up manually.
4. **Single source of truth for the id-to-host-name rule.** A one-line helper (`server_unit_name(server_id) -> str(server_id)`) lives in `l4d2web/services/server_identity.py`. Every callsite that used to pass `server.name` to `l4d2ctl` or `journalctl` calls this. Future format tweaks (e.g. `srv-{id}`) are a one-line edit.
## Schema
`servers` (Alembic 0006):
- Drop the (unnamed) global `UNIQUE (name)` from the original 0001 schema.
- Add `UNIQUE (user_id, name)` as `uq_servers_user_name`.
- Column stays `name VARCHAR(128) NOT NULL`.
The migration uses `batch_alter_table(recreate="always")` with a `naming_convention` so the originally-anonymous unique can be referenced as `uq_servers_name` for `drop_constraint`.
## Code touchpoints
- `l4d2web/services/server_identity.py` (new)
- `l4d2web/models.py` — drop `unique=True` on `Server.name`; add `__table_args__` with the per-user unique.
- `l4d2web/alembic/versions/0006_server_name_per_user.py` (new)
- `l4d2web/services/l4d2_facade.py` — five `l4d2ctl` invocations switched to `server_unit_name(server.id)`. Parameter renamed to `unit_name` on `server_status` / `stream_server_logs`.
- `l4d2web/services/job_worker.py` — status refresh uses `server_unit_name(server.id)`. The `server_name` log-label variable still holds `server.name` (the display label); that's correct now and shows up in job logs as e.g. "starting initialize for My Practice".
- `l4d2web/routes/log_routes.py` — SSE log stream feeds `server_unit_name(server.id)` to `journalctl`.
- `l4d2web/routes/server_routes.py` — replace `validate_instance_name` with `_validate_display_name` (strip + non-empty + length ≤128). Broaden the `IntegrityError` handler to disambiguate `servers.name` (409 "name already in use") from `servers.port` (409 "port already in use") via the underlying SQLite error string.
- `l4d2web/services/security.py``validate_instance_name` deleted (no remaining callers).
- `l4d2web/templates/servers.html` — name input gains `maxlength="128"`.
## Failure modes
- **Name with shell metacharacters reaches a host command.** Cannot happen — the host call now receives only `str(server.id)` (digits). The display name is never passed through `l4d2ctl`.
- **Two servers under the same user with the same name.** Blocked at the DB layer (`uq_servers_user_name`); surfaced as a 409 "name already in use" with no row written.
- **Migration on a DB with existing servers.** `batch_alter_table(recreate="always")` rebuilds the table preserving rows; the new per-user constraint is satisfied trivially since the old global constraint already enforced strict uniqueness.
## Verification
1. `python -m pytest l4d2web l4d2host deploy` from the repo root — green.
2. Stepwise migration on a fresh sqlite (upgrade to 0005, insert two users + a server, upgrade to 0006): row preserved, second user can take the same name, same user cannot (UNIQUE constraint failed: servers.user_id, servers.name).
3. Post-deploy on the test host: create a server named `"My Practice"` (with the space), confirm the systemd unit is `left4me-server@<id>.service`, confirm `/var/lib/left4me/runtime/<id>/merged` is mounted on start, confirm log streaming still works.
## Operator note
After deploy, on the test host: stop and remove any pre-existing `left4me-server@<old-name>.service` units and their `/var/lib/left4me/{instances,runtime}/<old-name>/` directories. The web app no longer references them.

View file

@ -0,0 +1,44 @@
"""server name unique per user
Revision ID: 0006_server_name_per_user
Revises: 0005_script_overlays
Create Date: 2026-05-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0006_server_name_per_user"
down_revision: Union[str, Sequence[str], None] = "0005_script_overlays"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# 0001_initial defined `name` with column-level `unique=True`, which SQLite
# stored as an unnamed UNIQUE constraint. The naming_convention here lets
# batch_alter_table refer to it as "uq_servers_name" so we can drop it before
# recreating the table with the new (user_id, name) composite.
_NAMING_CONVENTION = {"uq": "uq_%(table_name)s_%(column_0_name)s"}
def upgrade() -> None:
with op.batch_alter_table(
"servers",
naming_convention=_NAMING_CONVENTION,
recreate="always",
) as batch_op:
batch_op.drop_constraint("uq_servers_name", type_="unique")
batch_op.create_unique_constraint(
"uq_servers_user_name", ["user_id", "name"]
)
def downgrade() -> None:
with op.batch_alter_table(
"servers",
naming_convention=_NAMING_CONVENTION,
recreate="always",
) as batch_op:
batch_op.drop_constraint("uq_servers_user_name", type_="unique")
batch_op.create_unique_constraint("uq_servers_name", ["name"])

View file

@ -123,11 +123,14 @@ class BlueprintOverlay(Base):
class Server(Base): class Server(Base):
__tablename__ = "servers" __tablename__ = "servers"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_servers_user_name"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False) blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False) desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False)
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False) actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)

View file

@ -5,6 +5,7 @@ from l4d2web.auth import current_user, require_login
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Server from l4d2web.models import Server
from l4d2web.services import l4d2_facade as facade from l4d2web.services import l4d2_facade as facade
from l4d2web.services.server_identity import server_unit_name
bp = Blueprint("logs", __name__) bp = Blueprint("logs", __name__)
@ -27,7 +28,7 @@ def stream_server_logs(server_id: int) -> Response:
return Response(status=404) return Response(status=404)
def generate(): def generate():
for line in facade.stream_server_logs(server.name, lines=200, follow=True): for line in facade.stream_server_logs(server_unit_name(server.id), lines=200, follow=True):
if line == "": if line == "":
yield ": keepalive\n\n" yield ": keepalive\n\n"
else: else:

View file

@ -6,12 +6,25 @@ from l4d2web.auth import current_user, require_login
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Blueprint as BlueprintModel from l4d2web.models import Blueprint as BlueprintModel
from l4d2web.models import Job, Server from l4d2web.models import Job, Server
from l4d2web.services.security import validate_instance_name
bp = Blueprint("server", __name__) bp = Blueprint("server", __name__)
_NAME_MAX_LENGTH = 128
def _validate_display_name(raw: object) -> str:
if not isinstance(raw, str):
raise ValueError("name must be a string")
cleaned = raw.strip()
if not cleaned:
raise ValueError("name must not be empty")
if len(cleaned) > _NAME_MAX_LENGTH:
raise ValueError("name too long")
return cleaned
def _allocate_next_port(db) -> int | None: def _allocate_next_port(db) -> int | None:
start = int(current_app.config["PORT_RANGE_START"]) start = int(current_app.config["PORT_RANGE_START"])
end = int(current_app.config["PORT_RANGE_END"]) end = int(current_app.config["PORT_RANGE_END"])
@ -51,7 +64,7 @@ def create_server() -> Response:
payload = request.get_json(silent=True) if json_response else request.form payload = request.get_json(silent=True) if json_response else request.form
try: try:
name = validate_instance_name(str(payload["name"])) name = _validate_display_name(payload["name"])
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError):
return Response("invalid server name", status=400) return Response("invalid server name", status=400)
@ -82,8 +95,11 @@ def create_server() -> Response:
try: try:
db.flush() db.flush()
except IntegrityError: except IntegrityError as exc:
db.rollback() db.rollback()
detail = str(exc.orig) if exc.orig is not None else str(exc)
if "servers.name" in detail:
return Response("name already in use", status=409)
return Response("port already in use", status=409) return Response("port already in use", status=409)
server_id = server.id server_id = server.id

View file

@ -21,6 +21,7 @@ from l4d2web.models import (
WorkshopItem, WorkshopItem,
) )
from l4d2web.services.host_commands import CommandCancelledError from l4d2web.services.host_commands import CommandCancelledError
from l4d2web.services.server_identity import server_unit_name
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"} TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
@ -570,7 +571,7 @@ def refresh_server_actual_state(server_id: int) -> str:
server = db.scalar(select(Server).where(Server.id == server_id)) server = db.scalar(select(Server).where(Server.id == server_id))
if server is None: if server is None:
return "unknown" return "unknown"
status = l4d2_facade.server_status(server.name) status = l4d2_facade.server_status(server_unit_name(server.id))
server.actual_state = status.state server.actual_state = status.state
server.actual_state_updated_at = now server.actual_state_updated_at = now
server.updated_at = now server.updated_at = now

View file

@ -14,6 +14,7 @@ from l4d2web.models import (
WorkshopItem, WorkshopItem,
) )
from l4d2web.services import host_commands from l4d2web.services import host_commands
from l4d2web.services.server_identity import server_unit_name
from l4d2web.services.spec_yaml import write_temp_spec from l4d2web.services.spec_yaml import write_temp_spec
from l4d2web.services.workshop_paths import cache_path from l4d2web.services.workshop_paths import cache_path
@ -83,7 +84,7 @@ def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_can
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs)) spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs))
try: try:
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "initialize", server.name, "-f", str(spec_path)], ["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
should_cancel=should_cancel, should_cancel=should_cancel,
@ -176,7 +177,7 @@ def _check_workshop_overlay_caches(*, blueprint_id: int) -> None:
def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "start", server.name], ["l4d2ctl", "start", server_unit_name(server.id)],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
should_cancel=should_cancel, should_cancel=should_cancel,
@ -186,7 +187,7 @@ def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=N
def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "stop", server.name], ["l4d2ctl", "stop", server_unit_name(server.id)],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
should_cancel=should_cancel, should_cancel=should_cancel,
@ -196,7 +197,7 @@ def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=No
def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "delete", server.name], ["l4d2ctl", "delete", server_unit_name(server.id)],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
should_cancel=should_cancel, should_cancel=should_cancel,
@ -206,15 +207,15 @@ def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=
def reset_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def reset_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "reset", server.name], ["l4d2ctl", "reset", server_unit_name(server.id)],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
should_cancel=should_cancel, should_cancel=should_cancel,
) )
def server_status(server_name: str) -> ServerStatus: def server_status(unit_name: str) -> ServerStatus:
result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"]) result = host_commands.run_command(["l4d2ctl", "status", unit_name, "--json"])
payload = json.loads(result.stdout or "{}") payload = json.loads(result.stdout or "{}")
return ServerStatus( return ServerStatus(
state=str(payload.get("state", "unknown")), state=str(payload.get("state", "unknown")),
@ -223,7 +224,7 @@ def server_status(server_name: str) -> ServerStatus:
) )
def stream_server_logs(server_name: str, *, lines: int = 200, follow: bool = True): def stream_server_logs(unit_name: str, *, lines: int = 200, follow: bool = True):
command = ["l4d2ctl", "logs", server_name, "--lines", str(lines)] command = ["l4d2ctl", "logs", unit_name, "--lines", str(lines)]
command.append("--follow" if follow else "--no-follow") command.append("--follow" if follow else "--no-follow")
return host_commands.stream_command(command) return host_commands.stream_command(command)

View file

@ -1,17 +1,3 @@
import re
_INSTANCE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def validate_instance_name(raw: str) -> str:
if not _INSTANCE_NAME_RE.fullmatch(raw):
raise ValueError(
"instance name must match [a-z0-9][a-z0-9_-]{0,63}"
)
return raw
def validate_overlay_ref(raw: str) -> str: def validate_overlay_ref(raw: str) -> str:
if raw != raw.strip(): if raw != raw.strip():
raise ValueError("overlay ref must not have leading or trailing whitespace") raise ValueError("overlay ref must not have leading or trailing whitespace")

View file

@ -0,0 +1,5 @@
def server_unit_name(server_id: int) -> str:
"""Host-side identifier for a server (systemd unit suffix and on-disk
directory name). Lives in one place so a future format change is a
single-line edit."""
return str(server_id)

View file

@ -39,7 +39,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label> <label>Name <input name="name" required maxlength="128"></label>
<label>Port <label>Port
<input name="port" type="number" min="1" max="65535" placeholder="auto"> <input name="port" type="number" min="1" max="65535" placeholder="auto">
<span class="field-hint">Leave empty for the next available port.</span> <span class="field-hint">Leave empty for the next available port.</span>

View file

@ -84,7 +84,7 @@ def test_initialize_uses_l4d2ctl_with_latest_blueprint_data(
server_id, _ = server_with_blueprint server_id, _ = server_with_blueprint
initialize_server(server_id) initialize_server(server_id)
assert calls[0][:3] == ["l4d2ctl", "initialize", "alpha"] assert calls[0][:3] == ["l4d2ctl", "initialize", str(server_id)]
assert calls[0][3] == "-f" assert calls[0][3] == "-f"
@ -122,12 +122,13 @@ def test_install_and_lifecycle_commands_use_l4d2ctl(
reset_server(server_id) reset_server(server_id)
delete_server(server_id) delete_server(server_id)
unit = str(server_id)
assert calls == [ assert calls == [
["l4d2ctl", "install"], ["l4d2ctl", "install"],
["l4d2ctl", "start", "alpha"], ["l4d2ctl", "start", unit],
["l4d2ctl", "stop", "alpha"], ["l4d2ctl", "stop", unit],
["l4d2ctl", "reset", "alpha"], ["l4d2ctl", "reset", unit],
["l4d2ctl", "delete", "alpha"], ["l4d2ctl", "delete", unit],
] ]

View file

@ -140,8 +140,8 @@ def test_create_server_duplicate_port(user_client_with_blueprints) -> None:
assert servers[0].name == "server-1" assert servers[0].name == "server-1"
@pytest.mark.parametrize("bad_name", ["..", "../escape", "foo/bar", " foo", "Foo"]) @pytest.mark.parametrize("bad_name", ["", " ", "x" * 129])
def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_name: str) -> None: def test_create_server_rejects_invalid_display_names(user_client_with_blueprints, bad_name: str) -> None:
client, data = user_client_with_blueprints client, data = user_client_with_blueprints
response = client.post( response = client.post(
"/servers", "/servers",
@ -158,6 +158,102 @@ def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_nam
assert session.scalars(select(Server)).all() == [] assert session.scalars(select(Server)).all() == []
@pytest.mark.parametrize("name", ["My Practice", "räumlich", "alpha/beta", "..", "Foo"])
def test_create_server_accepts_free_form_display_names(user_client_with_blueprints, name: str) -> None:
client, data = user_client_with_blueprints
response = client.post(
"/servers",
data={"name": name, "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
from sqlalchemy import select
from l4d2web.models import Server
with session_scope() as session:
server = session.scalars(select(Server)).one()
assert server.name == name
def test_create_server_strips_surrounding_whitespace_in_name(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
response = client.post(
"/servers",
data={"name": " spaced ", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
from sqlalchemy import select
from l4d2web.models import Server
with session_scope() as session:
server = session.scalars(select(Server)).one()
assert server.name == "spaced"
def test_create_server_rejects_duplicate_name_for_same_user(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
first = client.post(
"/servers",
data={"name": "practice", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert first.status_code == 302
second = client.post(
"/servers",
data={"name": "practice", "port": "27016", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert second.status_code == 409
assert b"name already in use" in second.data
def test_create_server_allows_same_name_for_different_users(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'two_users.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
alice = User(username="alice", password_digest=hash_password("secret"), admin=False)
bob = User(username="bob", password_digest=hash_password("secret"), admin=False)
session.add_all([alice, bob])
session.flush()
alice_bp = Blueprint(user_id=alice.id, name="bp", arguments="[]", config="[]")
bob_bp = Blueprint(user_id=bob.id, name="bp", arguments="[]", config="[]")
session.add_all([alice_bp, bob_bp])
session.flush()
alice_id, bob_id = alice.id, bob.id
alice_bp_id, bob_bp_id = alice_bp.id, bob_bp.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = alice_id
sess["csrf_token"] = "test-token"
alice_resp = client.post(
"/servers",
data={"name": "practice", "port": "27015", "blueprint_id": str(alice_bp_id)},
headers={"X-CSRF-Token": "test-token"},
)
assert alice_resp.status_code == 302
with client.session_transaction() as sess:
sess["user_id"] = bob_id
sess["csrf_token"] = "test-token"
bob_resp = client.post(
"/servers",
data={"name": "practice", "port": "27016", "blueprint_id": str(bob_bp_id)},
headers={"X-CSRF-Token": "test-token"},
)
assert bob_resp.status_code == 302
def test_create_server_with_empty_port_auto_assigns(user_client_with_blueprints) -> None: def test_create_server_with_empty_port_auto_assigns(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints client, data = user_client_with_blueprints
response = client.post( response = client.post(