composer: /m/stl, a bounded export route

The route lives under the gated prefix from IDENTITY-CONTRACT.md section 5, so
gating it later is a proxy change and not a code change. It ships OPEN, because
/m/ is not yet gated and no membership system exists to gate it -- recorded in
section 8 of that document and cited in the module docstring, so a successor
reads it as a decision rather than an oversight.

model/stl with a Content-Disposition filename carrying the input_id. The
browser saves it without JavaScript assembling a blob on the happy path. The
route rebuilds from the query parameters rather than caching what /api/build
just made: the file is a function of the URL, and the composer goes on holding
no state between requests. Persistence is its own piece of work and should not
arrive here by accident.

MECHCOMP_MAX_EXPORT_MM bounds it, defaulting to 3048 -- one ten-foot member.
The ceiling exists because this endpoint is unauthenticated on a public name
and member_length_ft is an unbounded number whose value alone decides the size
of the computation and the download. /api/build has the same exposure with a
constant-size answer; export is where bounding becomes worth it.

3048 is not a claim that longer members are wrong. A hundred-foot member is a
real artifact and nobody prints one in a piece -- it gets sectioned. The export
ceiling and the member catalogue answer different questions and conflating them
would be the mistake. The limit is configuration rather than a query parameter
because a limit the caller can raise is not a limit, and an unparseable or
non-positive setting falls back to the default rather than disabling the bound:
a typo must not leave a limit that exists in the documentation and nowhere else.

Over the ceiling is a 413 naming the length, the limit and the key, and it
refuses rather than truncates. A truncated export would ship a 3048 mm file
whose design record describes a 30480 mm member -- the same silent wrongness
the length_view control was added to prevent, arriving by a different door.

ProfileRejected is a 422 with the reason intact, an unknown family a 404, and
only a genuinely unexpected exception a 500. Refusals are text/plain so the
download control can show them and a person who hit the URL by hand can read
it. A rejection is the compiler working.

Two things changed while building rather than after. The type coercion was
extracted from build_payload into typed_overrides and is now shared: had the
export coerced differently from the view, the downloaded file would not be the
part on screen, and neither would have looked wrong on its own. And the button
re-sends the query the current drawing came from rather than reading the
controls when pressed, so a half-typed number in a text box cannot export
something that was never displayed.

The page's JavaScript was parsed with node --check before landing. The
download handler rebalanced braces around the data.ok block, which is exactly
the kind of edit that compiles as a Python string and breaks in a browser.

16 tests. Mutation-proven: never applying the ceiling fails 4, truncating
instead of refusing fails 4, a bad environment value disabling the bound fails
5, the export using its own coercion fails 1, a rejection becoming a 500 fails
2. Restoration verified by checksum, PYTHONDONTWRITEBYTECODE=1 throughout.

One narrow margin worth recording: the shared-coercion guarantee rests on a
single test, test_the_export_is_the_part_the_view_shows. It is the only thing
that failed under M4. Deleting it would silently remove the only check that the
file matches the drawing.

Suite 643 passed.
This commit is contained in:
2026-09-12 11:00:11 -05:00
parent 0545b79674
commit cdde394bd8
2 changed files with 414 additions and 9 deletions
+233 -9
View File
@@ -21,9 +21,23 @@ WHAT IT SHOWS
layer that does not exist yet. layer that does not exist yet.
WHAT IT DOES NOT DO WHAT IT DOES NOT DO
It does not export STL, and it does not persist anything. It is a viewer and It does not persist anything. Every design exists for the duration of one
a control panel over ``build()``. Rejections are shown as messages rather request, and ``/m/stl`` rebuilds from the same query parameters rather than
than errors, because a rejection is the compiler doing its job. caching what ``/api/build`` just made -- persistence is its own piece of
work and should not arrive here by accident.
Rejections are shown as messages rather than errors, because a rejection is
the compiler doing its job.
THE GATED PREFIX
Everything requiring membership lives under ``/m/``. See
``docs/IDENTITY-CONTRACT.md`` section 5: the proxy gets one location block,
written once, so gating a new endpoint later is choosing a URL here rather
than changing shared infrastructure.
``/m/stl`` ships OPEN, because ``/m/`` is not yet gated and no membership
system exists to gate it. That is recorded in section 8 of that document,
not overlooked.
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,7 +46,7 @@ import json
import os import os
import traceback import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, NamedTuple, Tuple
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from mechcomp import svg from mechcomp import svg
@@ -65,6 +79,29 @@ ENUM_PARAMS: Dict[str, List[str]] = {
"length_view": ["Preview", "Full Length"], "length_view": ["Preview", "Full Length"],
} }
# The longest member this service will export, in millimetres.
#
# WHY A CEILING AT ALL
# ``/m/stl`` is reachable without authentication on a public name, and
# ``member_length_ft`` is an unbounded number. The response body grows with
# nothing but that parameter, so a request for a ten-thousand-foot member
# is a large computation and a large download asked for by one short URL.
# ``/api/build`` has the same exposure and a constant-size answer; export
# is where it becomes worth bounding.
#
# WHY 3048, AND WHAT IT IS NOT SAYING
# One ten-foot member -- the bottom of the range ROADMAP section 2 gives
# for members. It is NOT a claim that longer members are wrong. A hundred
# foot member is a real artifact and nobody prints one in a piece; it gets
# sectioned. The export ceiling and the member catalogue answer different
# questions and conflating them would be the mistake.
#
# WHY CONFIGURATION RATHER THAN A PARAMETER
# A limit the caller can raise is not a limit. The deployment can lift this
# through MECHCOMP_MAX_EXPORT_MM in mechcomp.env without a code change; a
# query parameter could not be trusted to.
DEFAULT_MAX_EXPORT_MM = 3048.0
# Parameters that apply to every profile, in the order they make sense to a # Parameters that apply to every profile, in the order they make sense to a
# person: what the stock is, how it fits, then how thick the printed walls are. # person: what the stock is, how it fits, then how thick the printed walls are.
# #
@@ -129,12 +166,19 @@ def profile_params(family, profile: str) -> List[Tuple[str, List[str]]]:
return groups return groups
def build_payload(family_name: str, profile: str, def typed_overrides(family, overrides: Dict[str, Any]) -> Dict[str, Any]:
overrides: Dict[str, Any], """
author: str = "") -> Dict[str, Any]: Coerce raw query strings to the types the family's defaults declare.
from mechcomp.profiles import ProfileRejected, build
family = families()[family_name] Shared by the view and the export deliberately. If the two coerced
differently, the downloaded file would not be the part on screen -- and
the difference would be invisible, because both would look correct on
their own.
An unparseable value is dropped rather than rejected, leaving the family
default: the composer is a viewer, and a half-typed number in a text box
should not blank the drawing.
"""
typed: Dict[str, Any] = {} typed: Dict[str, Any] = {}
for key, raw in overrides.items(): for key, raw in overrides.items():
if key not in family.defaults: if key not in family.defaults:
@@ -161,6 +205,16 @@ def build_payload(family_name: str, profile: str,
typed[key] = raw typed[key] = raw
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
return typed
def build_payload(family_name: str, profile: str,
overrides: Dict[str, Any],
author: str = "") -> Dict[str, Any]:
from mechcomp.profiles import ProfileRejected, build
family = families()[family_name]
typed = typed_overrides(family, overrides)
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"family": family_name, "family": family_name,
@@ -192,6 +246,74 @@ def build_payload(family_name: str, profile: str,
return payload return payload
class ExportRefusal(NamedTuple):
"""A refusal the caller should see as text, not as a stack trace."""
status: int
message: str
def build_export(family_name: str, profile: str,
overrides: Dict[str, Any],
author: str = "",
ceiling: float = None) -> Any:
"""
Build a member and return ``(filename, bytes)``, or an ``ExportRefusal``.
Rebuilt from the query parameters rather than taken from anything
``/api/build`` cached, so the file is a function of the URL and the
composer keeps holding no state.
A rejection is not an error. ``ProfileRejected`` and ``ExportRefused`` are
both the compiler declining to vouch for something, and both should reach
the caller as readable text with a 4xx. Only an unexpected exception is a
500, because only that is the compiler failing rather than working.
"""
from mechcomp import stl
from mechcomp.profiles import ProfileRejected, build
if ceiling is None:
ceiling = max_export_mm()
family = families().get(family_name)
if family is None:
return ExportRefusal(404, "Unknown family: %s" % family_name)
typed = typed_overrides(family, overrides)
try:
result = build(family=family_name, profile=profile, params=typed,
author=author)
except ProfileRejected as exc:
return ExportRefusal(422, str(exc))
except KeyError:
return ExportRefusal(404, "Unknown profile: %s" % profile)
except Exception as exc: # noqa: BLE001
return ExportRefusal(500, "%s: %s" % (type(exc).__name__, exc))
length = result.report["LENGTH_MM"]
if length > ceiling:
# Named rather than truncated. Silently exporting a shorter member than
# was asked for would produce a file whose design record describes
# something else -- the exact failure the length_view control was added
# to prevent, arriving by a different door.
return ExportRefusal(
413,
"This member is %g mm long and the export limit is %g mm. "
"Nothing is truncated: a shorter file would carry a design record "
"describing a different object. Raise MECHCOMP_MAX_EXPORT_MM in "
"the deployment's environment file if a longer export is wanted."
% (length, ceiling))
try:
data = stl.member_stl(result)
except stl.ExportRefused as exc:
return ExportRefusal(422, str(exc))
except Exception as exc: # noqa: BLE001
return ExportRefusal(500, "%s: %s" % (type(exc).__name__, exc))
return (stl.stl_filename(result), data)
PAGE = """<!doctype html> PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"> <html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -235,6 +357,12 @@ PAGE = """<!doctype html>
.toggles { margin-bottom:12px; font-size:12.5px; } .toggles { margin-bottom:12px; font-size:12.5px; }
.toggles label { display:inline-flex; grid-template-columns:none; gap:5px; margin-right:14px; } .toggles label { display:inline-flex; grid-template-columns:none; gap:5px; margin-right:14px; }
.toggles input { width:auto; } .toggles input { width:auto; }
.export { margin-top:14px; display:flex; align-items:center; gap:10px; }
.export button { font:inherit; font-size:12.5px; padding:6px 13px; cursor:pointer;
border:1px solid var(--accent); border-radius:5px;
background:var(--accent); color:#fff; }
.export button:disabled { background:#c3ccd4; border-color:#c3ccd4; cursor:default; }
#export-note { font-size:12px; color:#7b8794; }
</style></head><body> </style></head><body>
<header> <header>
<h1>Mechanical Compiler</h1> <h1>Mechanical Compiler</h1>
@@ -263,6 +391,10 @@ PAGE = """<!doctype html>
<label><input type="checkbox" id="t-stock" checked> stock</label> <label><input type="checkbox" id="t-stock" checked> stock</label>
</div> </div>
<div id="out"></div> <div id="out"></div>
<div class="export">
<button id="download" type="button">Download STL</button>
<span id="export-note"></span>
</div>
<div class="cols"> <div class="cols">
<details open><summary>Report</summary><pre id="report"></pre></details> <details open><summary>Report</summary><pre id="report"></pre></details>
<details><summary>Design record</summary><pre id="record"></pre></details> <details><summary>Design record</summary><pre id="record"></pre></details>
@@ -272,6 +404,11 @@ PAGE = """<!doctype html>
<script> <script>
let state = { family:"3x", profile:"Y", values:{} }; let state = { family:"3x", profile:"Y", values:{} };
// The query the current drawing came from. The export re-sends exactly this,
// so the file is the part on screen rather than whatever the controls hold at
// the moment the button is pressed.
let lastQuery = new URLSearchParams();
async function refresh(sendValues) { async function refresh(sendValues) {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.set("family", state.family); q.set("family", state.family);
@@ -279,6 +416,7 @@ async function refresh(sendValues) {
const author = document.getElementById("author").value.trim(); const author = document.getElementById("author").value.trim();
if (author) q.set("author", author); if (author) q.set("author", author);
if (sendValues) for (const [k,v] of Object.entries(state.values)) q.set(k, v); if (sendValues) for (const [k,v] of Object.entries(state.values)) q.set(k, v);
lastQuery = q;
const data = await (await fetch("/api/build?" + q.toString())).json(); const data = await (await fetch("/api/build?" + q.toString())).json();
const fam = document.getElementById("family"); const fam = document.getElementById("family");
@@ -335,6 +473,10 @@ async function refresh(sendValues) {
} else { } else {
out.innerHTML = '<div class="bad"><b>Not buildable</b>' + escapeHtml(data.message) + '</div>'; out.innerHTML = '<div class="bad"><b>Not buildable</b>' + escapeHtml(data.message) + '</div>';
} }
document.getElementById("download").disabled = !data.ok;
if (!data.ok) {
document.getElementById("export-note").textContent = "";
}
} }
function escapeHtml(s) { function escapeHtml(s) {
@@ -352,6 +494,38 @@ function applyToggles() {
for (const id of ["t-material","t-cavity","t-stock"]) for (const id of ["t-material","t-cavity","t-stock"])
document.getElementById(id).onchange = applyToggles; document.getElementById(id).onchange = applyToggles;
document.getElementById("download").onclick = async () => {
const btn = document.getElementById("download");
const note = document.getElementById("export-note");
btn.disabled = true;
note.textContent = "building...";
try {
const res = await fetch("/m/stl?" + lastQuery.toString());
if (!res.ok) {
// A refusal is text. Showing it is the point -- a rejection is the
// compiler working, and the message names the parameter and the limit.
note.textContent = await res.text();
return;
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const match = cd.match(/filename="([^"]+)"/);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = match ? match[1] : "member.stl";
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
note.textContent = match ? match[1] : "";
} catch (err) {
note.textContent = String(err);
} finally {
btn.disabled = false;
}
};
document.getElementById("author").onchange = () => refresh(true); document.getElementById("author").onchange = () => refresh(true);
refresh(false); refresh(false);
@@ -393,6 +567,35 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, json.dumps(payload).encode("utf-8"), self._send(200, json.dumps(payload).encode("utf-8"),
"application/json; charset=utf-8") "application/json; charset=utf-8")
return return
if parsed.path == "/m/stl":
q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
family = q.pop("family", "3x")
profile = q.pop("profile", "Y")
author = q.pop("author", "")
try:
outcome = build_export(family, profile, q, author)
except Exception as exc: # noqa: BLE001
outcome = ExportRefusal(
500, "%s: %s" % (type(exc).__name__, exc))
if isinstance(outcome, ExportRefusal):
# Plain text, not JSON and not HTML. The download control shows
# this string directly, and a person who hit the URL by hand
# should be able to read the reason without a viewer.
self._send(outcome.status,
outcome.message.encode("utf-8"),
"text/plain; charset=utf-8")
return
filename, data = outcome
self.send_response(200)
self.send_header("Content-Type", "model/stl")
self.send_header("Content-Disposition",
'attachment; filename="%s"' % filename)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")
@@ -443,6 +646,27 @@ def resolve_binding(env_file: str = ENV_FILE) -> Tuple[str, int]:
return host, port return host, port
def max_export_mm(env_file: str = ENV_FILE) -> float:
"""
The export ceiling: the real environment first, then the deployment file,
then the built-in default.
An unparseable or non-positive value falls back to the default rather than
disabling the limit. A typo in an environment file should not silently
remove a bound -- that is the failure mode where a limit exists in the
documentation and nowhere else.
"""
env = read_env_file(env_file)
raw = (os.environ.get("MECHCOMP_MAX_EXPORT_MM")
or env.get("MECHCOMP_MAX_EXPORT_MM")
or "")
try:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_MAX_EXPORT_MM
return value if value > 0 else DEFAULT_MAX_EXPORT_MM
def base_url(env_file: str = ENV_FILE) -> str: def base_url(env_file: str = ENV_FILE) -> str:
"""The URL a person actually types. Empty when nothing declares one.""" """The URL a person actually types. Empty when nothing declares one."""
env = read_env_file(env_file) env = read_env_file(env_file)
+181
View File
@@ -0,0 +1,181 @@
"""
The export route: what it serves, what it refuses, and what it will not truncate.
WHY THE CEILING IS TESTED AS HARD AS THE GEOMETRY
``/m/stl`` is reachable without authentication on a public name, and
``member_length_ft`` is an unbounded number. The response body grows with
nothing but that parameter. The bound is the only thing standing between a
short URL and a very large computation, so a bound that silently stopped
applying would be worse than none -- it would be documented, believed, and
absent.
"""
from __future__ import annotations
import struct
import pytest
app = pytest.importorskip("mechcomp.web.app")
stl = pytest.importorskip("mechcomp.stl")
CEILING = app.DEFAULT_MAX_EXPORT_MM
def export(ceiling=None, **overrides):
return app.build_export("3x", "Y", dict(overrides), "", ceiling)
# ---------------------------------------------------------------------------
# It serves a file
# ---------------------------------------------------------------------------
def test_a_default_export_is_a_named_stl():
filename, data = export()
assert filename.endswith(".stl")
assert len(data) == 84 + 50 * struct.unpack("<I", data[80:84])[0]
def test_the_filename_carries_the_design_id():
from mechcomp.profiles import build
filename, _ = export()
assert build("3x", "Y").record.input_id in filename
def test_the_header_identifies_the_design():
_, data = export()
text = data[:80].rstrip(b"\0").decode("ascii")
assert text.startswith("mechcomp")
assert "input=" in text and "build=" in text
def test_the_export_is_the_part_the_view_shows():
"""
Both go through ``typed_overrides``. If they coerced differently the file
would not be the drawing, and neither would look wrong on its own -- which
is why the coercion is shared rather than written twice.
"""
params = {"preview_length_mm": "37.5", "bundle_count": "2"}
payload = app.build_payload("3x", "Y", dict(params))
_, data = export(**params)
zs = []
count = struct.unpack("<I", data[80:84])[0]
for n in range(count):
base = 84 + n * 50 + 12
for v in range(3):
zs.append(struct.unpack("<3f", data[base + v * 12:base + v * 12 + 12])[2])
assert max(zs) == pytest.approx(payload["report"]["LENGTH_MM"], rel=1e-6)
assert payload["values"]["bundle_count"] == 2
# ---------------------------------------------------------------------------
# The ceiling
# ---------------------------------------------------------------------------
def test_the_default_ceiling_is_one_ten_foot_member():
assert CEILING == 3048.0
def test_a_member_at_the_ceiling_exports():
"""The boundary is inclusive, and this is the positive control for below."""
filename, data = export(length_view="Full Length", member_length_ft=10)
assert filename.endswith(".stl")
assert len(data) > 84
def test_a_member_over_the_ceiling_is_refused():
outcome = export(length_view="Full Length", member_length_ft=100)
assert isinstance(outcome, app.ExportRefusal)
assert outcome.status == 413
def test_the_refusal_names_the_limit_and_how_to_raise_it():
"""
A refusal a person cannot act on is a wall. This one says the length, the
limit, and the configuration key.
"""
outcome = export(length_view="Full Length", member_length_ft=100)
assert "30480" in outcome.message
assert "3048" in outcome.message
assert "MECHCOMP_MAX_EXPORT_MM" in outcome.message
def test_nothing_is_truncated_to_fit():
"""
The failure this prevents: exporting a 3048 mm file for a 30480 mm request
would ship a design record describing a different object -- the same class
of silent wrongness the length_view control was added to stop, arriving by
a different door.
"""
outcome = export(length_view="Full Length", member_length_ft=100)
assert isinstance(outcome, app.ExportRefusal)
assert "truncated" in outcome.message.lower()
def test_the_ceiling_is_configurable():
"""Raising it lets the same member through, which proves the gate is the
ceiling rather than something else about a long member."""
assert isinstance(export(length_view="Full Length", member_length_ft=100),
app.ExportRefusal)
filename, data = export(ceiling=1e9, length_view="Full Length",
member_length_ft=100)
assert filename.endswith(".stl")
def test_a_broken_ceiling_setting_falls_back_to_the_default(monkeypatch, tmp_path):
"""
A typo must not silently remove the bound. That is the failure where a
limit exists in the documentation and nowhere else.
"""
empty = tmp_path / "none.env"
empty.write_text("")
for bad in ("", "abc", "0", "-5", " "):
monkeypatch.setenv("MECHCOMP_MAX_EXPORT_MM", bad)
assert app.max_export_mm(str(empty)) == CEILING
monkeypatch.setenv("MECHCOMP_MAX_EXPORT_MM", "5000")
assert app.max_export_mm(str(empty)) == 5000.0
# ---------------------------------------------------------------------------
# Refusals are readable, not stack traces
# ---------------------------------------------------------------------------
def test_an_unknown_family_is_a_404():
outcome = app.build_export("9x", "Y", {}, "")
assert isinstance(outcome, app.ExportRefusal)
assert outcome.status == 404
def test_an_unknown_profile_is_not_a_500():
outcome = app.build_export("3x", "Nonexistent", {}, "")
assert isinstance(outcome, app.ExportRefusal)
assert outcome.status < 500
def test_a_rejected_profile_is_a_422_carrying_the_reason():
"""
A rejection is the compiler working. The message names the parameter and
the limit, as the reference did, and must survive to the caller.
"""
outcome = export(min_wall_mm=50)
assert isinstance(outcome, app.ExportRefusal)
assert outcome.status == 422
assert outcome.message
assert "Traceback" not in outcome.message
def test_a_buildable_member_is_not_refused():
"""Positive control: the refusals are not simply refusing everything."""
assert not isinstance(export(), app.ExportRefusal)
# ---------------------------------------------------------------------------
# Authorship travels with the export
# ---------------------------------------------------------------------------
def test_an_author_reaches_the_record_without_moving_the_file_identity():
plain = app.build_export("3x", "Y", {}, "")
signed = app.build_export("3x", "Y", {}, "someone@example.org")
assert plain[0] == signed[0], "input_id must not move with the author"
assert plain[1][:80] == signed[1][:80], "neither id may move"