diff --git a/.github/workflows/wfl-tests.yml b/.github/workflows/wfl-tests.yml
index 51e3c41..4c337f5 100644
--- a/.github/workflows/wfl-tests.yml
+++ b/.github/workflows/wfl-tests.yml
@@ -45,9 +45,10 @@ jobs:
echo "- Scribe: $(git -C lib/scribe rev-parse HEAD)"
echo '- Runner: blacksmith-2vcpu-ubuntu-2404 (Linux x64)'
echo '- Command: python3 scripts/run_tests.py --include-scribe'
+ echo '- HTTP checks: python3 -m unittest discover -s tests/integration -v'
} | tee -a "$GITHUB_STEP_SUMMARY"
- - name: Run Scriptorium and pinned Scribe suites
+ - name: Run WFL suites and HTTP configuration checks
shell: bash
env:
RESOLVED_IMAGE: ${{ steps.runtime.outputs.image }}
@@ -64,6 +65,7 @@ jobs:
python3 --version
wfl --version
python3 scripts/run_tests.py --include-scribe
+ python3 -m unittest discover -s tests/integration -v
'
- name: Clean up test container
diff --git a/.wflcfg b/.wflcfg
index 2174a56..34138cb 100644
--- a/.wflcfg
+++ b/.wflcfg
@@ -1,4 +1,4 @@
-# Scriptorium runtime configuration (WFL settings)
+# Scriptorium configuration (WFL runtime and application settings)
timeout_seconds = 60
logging_enabled = false
debug_report_enabled = false
@@ -9,6 +9,11 @@ log_level = info
# 0.0.0.0 -> listen on all interfaces (use behind a reverse proxy)
web_server_bind_address = 127.0.0.1
+# HTTP port, read by Scriptorium at boot rather than by the WFL runtime.
+# Use a whole number from 1 to 65535. Missing or invalid values use 8080.
+# Restart Scriptorium after changing it.
+web_server_port = 8080
+
# To terminate TLS directly in Scriptorium, point these at your PEM files and
# start with a `listen ... secured` port (see docs/ARCHITECTURE.md).
# web_server_tls_cert_file = /etc/scriptorium/tls/cert.pem
diff --git a/CLAUDE.md b/CLAUDE.md
index 3907df4..79e71d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -85,9 +85,11 @@ violate the standard.
its summary records the resolved image digest, runtime version, and source
revisions. See
[testing.md](testing.md) for commands, coverage limits, and merge evidence.
-- **`data_dir` is an application convention, not a WFL runtime feature.**
- `main.wfl` reads `.wflcfg` itself at boot and parses the key via
- `config_value_from` in `app/util.wfl`. The runtime ignores it.
+- **`data_dir` and `web_server_port` are application configuration.**
+ `main.wfl` reads `.wflcfg` itself at boot using helpers in `app/util.wfl`;
+ the runtime does not apply these keys. The HTTP port accepts whole numbers
+ from 1 to 65535 and defaults to 8080 for a missing file or setting, or an
+ empty, malformed, fractional, or out-of-range value. Restart after changing it.
## Known gaps worth knowing before you touch rendering
diff --git a/README.md b/README.md
index 6718d27..c511891 100644
--- a/README.md
+++ b/README.md
@@ -74,9 +74,12 @@ Open — you will be sent to `/install`. Choose a site
title, tagline, admin username, and password. When setup finishes you are
signed in at `/admin`. Add more users (admins or authors) under **Users**.
-> Bind address and TLS are set in `.wflcfg`. The server listens on
-> `127.0.0.1:8080` by default; set `web_server_bind_address = 0.0.0.0` to expose
-> it behind a reverse proxy.
+> Network settings are in `.wflcfg`. The server listens on `127.0.0.1:8080`
+> by default; set `web_server_bind_address = 0.0.0.0` to expose it behind a
+> reverse proxy. Scriptorium reads `web_server_port = 8080` at boot; change it
+> to a whole number from 1 to 65535 and restart to use a different HTTP port.
+> A missing file or setting, or an empty, malformed, fractional, or out-of-range
+> value, uses 8080. TLS settings are also in `.wflcfg`.
### Choosing a theme
@@ -94,8 +97,9 @@ theme_root = themes
> **A value runs to the end of the line.** `.wflcfg` supports whole-line `#`
> comments only, so `theme = logbie # my theme` sets the theme to
> `logbie # my theme` and every lookup misses. Put comments on their own line.
-> This applies to `data_dir` too. Boot warns when the configured theme
-> directory does not exist, which is what a trailing comment looks like.
+> This applies to `data_dir` and `web_server_port` too. Boot warns when the
+> configured theme directory does not exist, which is what a trailing comment
+> looks like.
A body template resolves as `//body/.html`, then
`//templates/.html`, then the base theme — so a theme
@@ -181,7 +185,7 @@ as a hidden `csrf_token` field) — requests without it get a 403.
```text
main.wfl Boot (open DB, migrate, backfill) + request loop + router + handlers
-.wflcfg WFL runtime config (bind address, TLS, body-size cap, data_dir)
+.wflcfg Runtime and app config (bind address, HTTP port, TLS, body-size cap, data_dir)
app/
util.wfl slugify, to_int, field_or, truncate, file_ext/stem, config_value_from (parsing is stdlib)
db.wfl SQLite schema + every query/execute helper
@@ -212,6 +216,7 @@ From the repository root, with Python 3.11+ and WFL on PATH:
```sh
python scripts/run_tests.py # all five Scriptorium suites
python scripts/run_tests.py --include-scribe # also test the pinned Scribe engine
+python -m unittest discover -s tests/integration -v # HTTP port configuration
python -m unittest discover -s tests/tooling -v
python scripts/check_repo_hygiene.py
```
diff --git a/TestPrograms/util.test.wfl b/TestPrograms/util.test.wfl
index e337e25..496d15c 100644
--- a/TestPrograms/util.test.wfl
+++ b/TestPrograms/util.test.wfl
@@ -74,6 +74,37 @@ describe "Scriptorium utilities":
expect config_value_from of "" and "data_dir" and "./data" to equal "./data"
end test
+ test "config_port_from defaults when absent or commented":
+ expect config_port_from of "" to equal 8080
+ expect config_port_from of "web_server_bind_address = 127.0.0.1" to equal 8080
+ expect config_port_from of "# web_server_port = 9090" to equal 8080
+ end test
+
+ test "config_port_from reads the configured port and trims whitespace":
+ expect config_port_from of "web_server_port = 9090" to equal 9090
+ expect config_port_from of " web_server_port = 12345 \r\n" to equal 12345
+ end test
+
+ test "config_port_from accepts the valid port boundaries":
+ expect config_port_from of "web_server_port = 1" to equal 1
+ expect config_port_from of "web_server_port = 65535" to equal 65535
+ end test
+
+ test "config_port_from rejects empty and nonnumeric values":
+ expect config_port_from of "web_server_port = " to equal 8080
+ expect config_port_from of "web_server_port = nope" to equal 8080
+ expect config_port_from of "web_server_port = true" to equal 8080
+ expect config_port_from of "web_server_port = null" to equal 8080
+ expect config_port_from of "web_server_port = 9090 # inline comment" to equal 8080
+ end test
+
+ test "config_port_from rejects fractional and out-of-range ports":
+ expect config_port_from of "web_server_port = 9090.5" to equal 8080
+ expect config_port_from of "web_server_port = 0" to equal 8080
+ expect config_port_from of "web_server_port = -1" to equal 8080
+ expect config_port_from of "web_server_port = 65536" to equal 8080
+ end test
+
test "install_validate rejects an empty title":
expect install_validate of "" and "ada" and "secret" and "secret" to equal "Site title is required."
end test
diff --git a/app/util.wfl b/app/util.wfl
index f7255e2..4147dfb 100644
--- a/app/util.wfl
+++ b/app/util.wfl
@@ -157,6 +157,20 @@ define action called config_value_from with parameters cfg_text and cfg_key and
return fallback
end action
+// config_port_from: resolve Scriptorium's HTTP port from .wflcfg text.
+// Missing or invalid values preserve the default listener on port 8080.
+define action called config_port_from with parameters cfg_text:
+ store raw_port as config_value_from of cfg_text and "web_server_port" and "8080"
+ store configured_port as to_int of raw_port and 8080
+ check if (configured_port is less than 1) or (configured_port is greater than 65535):
+ return 8080
+ end check
+ check if configured_port is not equal to (floor of configured_port):
+ return 8080
+ end check
+ return configured_port
+end action
+
// install_validate: first-run installer field checks. Pure (no I/O).
// Title and username are trimmed; passwords are compared as submitted.
define action called install_validate with parameters site_title and username and password and password_confirm:
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ace5590..4e3d27a 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -15,7 +15,7 @@ reads as deliberate rather than accidental.
```
Browser
- │ HTTP (127.0.0.1:8080)
+ │ HTTP (127.0.0.1:8080 by default)
▼
main.wfl ── listen on port ── main loop: wait for request ── route ──┐
│ │
@@ -61,6 +61,15 @@ main.wfl ── site_ext.wfl (+ defines the router and every request handler)
already exist), the request loop, the `route`-based dispatcher with the
first-run lock, and all handler actions.
+## Network configuration
+
+WFL reads `web_server_bind_address` and TLS settings from `.wflcfg`. Scriptorium
+reads `web_server_port` itself at boot, like `data_dir`, and passes the resolved
+port to `listen`. The default is 8080; the configured value must be a whole
+number from 1 to 65535. A missing file or setting, or an empty, malformed,
+fractional, or out-of-range value, falls back to 8080. Changes require a restart.
+The shared bind address remains `127.0.0.1`.
+
## Request lifecycle
WFL's web server is **pull-based and single-threaded**: the loop blocks on
diff --git a/main.wfl b/main.wfl
index 5559df5..90d392e 100644
--- a/main.wfl
+++ b/main.wfl
@@ -1315,6 +1315,8 @@ store cfg_text as ""
check if file exists at ".wflcfg":
change cfg_text to scribe_read_file of ".wflcfg"
end check
+// The HTTP port is an application setting; preserve 8080 for existing installs.
+store server_port as config_port_from of cfg_text
store data_dir as config_value_from of cfg_text and "data_dir" and ""
check if data_dir is not equal to "":
check if directory exists at data_dir:
@@ -1395,12 +1397,12 @@ otherwise:
create directory at uploads_dir
end check
-listen on port 8080 as web_server
-display "Scriptorium is running at http://127.0.0.1:8080"
+listen on port server_port as web_server
+display "Scriptorium is running at http://127.0.0.1:" with server_port
check if (install_is_done of db) is equal to yes:
display " admin: /admin"
otherwise:
- display " First run — open http://127.0.0.1:8080/install to set up your site"
+ display " First run — open http://127.0.0.1:" with server_port with "/install to set up your site"
end check
main loop:
diff --git a/testing.md b/testing.md
index 6e5f03e..81139f2 100644
--- a/testing.md
+++ b/testing.md
@@ -105,9 +105,22 @@ pin changes and changes affecting Scribe integration. CMS-specific Scribe
regressions remain in `TestPrograms/scribe.test.wfl` even when upstream tests
also cover related behavior.
+The HTTP port configuration checks start disposable Scriptorium processes and
+exercise `/install` using synthetic temporary databases:
+
+```sh
+python -m unittest discover -s tests/integration -v
+```
+
+These checks require WFL on `PATH` (or `WFL_EXECUTABLE` set to its executable)
+and an available loopback port 8080. They check an explicitly configured port,
+the default when the setting or file is absent, and startup URLs. They do not
+exercise installer submission or other complete CMS journeys. The WFL tests
+workflow runs them inside its disposable nightly container after the WFL suites.
+
| Existing suite | Direct command from the repository root | What it currently exercises |
|---|---|---|
-| Utilities | `wfl --test TestPrograms/util.test.wfl` | Slugs, number/field helpers, parsing, config values, installer validation |
+| Utilities | `wfl --test TestPrograms/util.test.wfl` | Slugs, number/field helpers, parsing, config values and valid/default ports, installer validation |
| Data | `wfl --test TestPrograms/db.test.wfl` | In-memory SQLite CRUD helpers, sessions, installer state, rate-limit records, legacy CSRF-column migration |
| Auth | `wfl --test TestPrograms/auth.test.wfl` | CSRF helper acceptance/rejection and session token binding |
| Scribe integration | `wfl --test TestPrograms/scribe.test.wfl` | Markdown, safe-marker/filter propagation, escaping, nested blockquotes |
@@ -180,8 +193,9 @@ keyboard behavior, or data integrity.
concrete restore or forward-repair plan. An in-memory migration test alone
does not prove crash recovery or database/upload consistency.
- **Configuration:** exercise unset/default keys and explicit values for
- `data_dir`, `theme`, and `theme_root`, including malformed values. Account for
- the current whole-line-comment parsing and out-of-tree theme paths.
+ `web_server_port`, `data_dir`, `theme`, and `theme_root`, including malformed
+ values. Account for the current whole-line-comment parsing and out-of-tree
+ theme paths.
- **Dependencies:** inspect the actual pinned Scribe diff, run both local and
upstream suites, and exercise affected rendering paths. Runtime upgrades also
require the application suites and affected web/SQLite/crypto boundaries.
@@ -210,7 +224,8 @@ moving: retain the run URL and digest with PR evidence so a later nightly does
not obscure which runtime was tested. The nightly workflow is not a declaration
that every nightly, platform, or production configuration is supported.
-There is no automated HTTP/browser journey suite in this repository. The
+Automated HTTP coverage is limited to startup port configuration and serving
+the installer form. Complete HTTP/browser journeys remain unautomated. The
scheduled Scribe updater only proposes dependency changes; its successful run
alone is not runtime test evidence.
@@ -248,7 +263,7 @@ remain a separate adoption item below.
| Gap | Required next step and trigger |
|---|---|
-| No router/HTTP or browser automation | Add real-boundary regression coverage with each affected behavior change; plan coverage of all critical journeys before the next production release. |
+| HTTP coverage is limited to port configuration; no browser automation | Add real-boundary regression coverage with each affected behavior change; plan coverage of all critical journeys before the next production release. |
| No declared compatibility matrix or release-candidate workflow | Define supported runtime/platform/configuration tuples and retain candidate results before the next production release. |
| No coverage measurement, performance budgets, or scheduled extended tests | Establish baselines and risk-based targets before claiming those properties; review at the next profile review. |
| Host protection settings are external | Maintainer verifies required checks and review rules on GitHub at adoption and after workflow changes. |
diff --git a/tests/integration/test_server_port.py b/tests/integration/test_server_port.py
new file mode 100644
index 0000000..06468c9
--- /dev/null
+++ b/tests/integration/test_server_port.py
@@ -0,0 +1,155 @@
+"""Exercise the configured listening port through a disposable Scriptorium site.
+
+Run sequentially with ``python -m unittest discover -s tests/integration -v``.
+WFL_EXECUTABLE may select a runtime; otherwise wfl must be on PATH. The default
+port cases require loopback port 8080 to be free and never stop other services.
+"""
+
+import http.client
+import os
+from pathlib import Path
+import shutil
+import socket
+import subprocess
+import tempfile
+import time
+import unittest
+
+
+SOURCE = Path(__file__).resolve().parents[2]
+STARTUP_SECONDS = 20
+BASE_CONFIG = (
+ "web_server_bind_address = 127.0.0.1\n"
+ "timeout_seconds = 60\n"
+ "logging_enabled = false\n"
+ "debug_report_enabled = false\n"
+)
+
+
+class ServerPortTests(unittest.TestCase):
+ def setUp(self):
+ requested = os.environ.get("WFL_EXECUTABLE", "wfl")
+ executable = shutil.which(requested)
+ self.assertIsNotNone(executable, f"WFL executable not found: {requested}")
+ self.executable = str(Path(executable).resolve())
+ self.temporary = tempfile.TemporaryDirectory(prefix="scriptorium-port-test-")
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name) / "site"
+ self.root.mkdir()
+ # The runtime searches parent directories for configuration, whereas
+ # Scriptorium reads only its own .wflcfg. Keep the absent-file test on
+ # loopback too, regardless of the developer's global runtime settings.
+ (self.root.parent / ".wflcfg").write_text(BASE_CONFIG, encoding="utf-8")
+ runtime_files = [
+ Path("main.wfl"),
+ Path("lib/scribe/src/scribe.wfl"),
+ Path("admin/templates/install.html"),
+ *(Path("app") / name for name in
+ ("util.wfl", "db.wfl", "auth.wfl", "render.wfl", "site_ext.wfl")),
+ ]
+ for relative in runtime_files:
+ source = SOURCE / relative
+ self.assertTrue(source.is_file(), f"Missing runtime source: {source}")
+ destination = self.root / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, destination)
+ # Never copy a checkout's database, uploads, configuration, or .git.
+ (self.root / "static").mkdir()
+ self.log_path = self.root.parent / "server.log"
+ self.log = self.log_path.open("wb")
+ self.addCleanup(self.log.close)
+ self.process = None
+ self.addCleanup(self.stop_server)
+
+ def stop_server(self):
+ if self.process is not None and self.process.poll() is None:
+ self.process.terminate()
+ try:
+ self.process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ self.process.kill()
+ self.process.wait(timeout=5)
+
+ def server_log(self):
+ return self.log_path.read_text(encoding="utf-8", errors="replace")
+
+ def available_port(self, requested=0):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
+ if os.name == "nt":
+ reservation.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
+ else:
+ # Permit a previous test's TIME_WAIT sockets, but not a listener.
+ reservation.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ reservation.bind(("127.0.0.1", requested))
+ except OSError as exc:
+ self.fail(
+ f"Prerequisite: loopback port {requested} must be free; "
+ f"no existing service was stopped ({exc})"
+ )
+ return reservation.getsockname()[1]
+
+ def assert_installer(self, expected_port, config):
+ if config is not None:
+ (self.root / ".wflcfg").write_text(
+ BASE_CONFIG + "data_dir = runtime-data\n" + config, encoding="utf-8"
+ )
+ # Also keep the old hardcoded port free when proving the configured
+ # case, so a regression can start and explain its actual bind in logs.
+ self.available_port(8080)
+ self.process = subprocess.Popen(
+ [self.executable, "main.wfl"], cwd=self.root,
+ stdin=subprocess.DEVNULL, stdout=self.log, stderr=subprocess.STDOUT,
+ )
+ deadline = time.monotonic() + STARTUP_SECONDS
+ last_error = "server has not responded"
+ while time.monotonic() < deadline:
+ if self.process.poll() is not None:
+ self.fail(
+ f"Server exited with {self.process.returncode} before serving "
+ f"port {expected_port}:\n{self.server_log()}"
+ )
+ connection = http.client.HTTPConnection("127.0.0.1", expected_port, timeout=1)
+ try:
+ connection.request("GET", "/install")
+ response = connection.getresponse()
+ body = response.read().decode("utf-8")
+ except (OSError, http.client.HTTPException) as exc:
+ last_error = str(exc)
+ else:
+ diagnostic = self.server_log()
+ self.assertEqual(response.status, 200, diagnostic + "\n" + body)
+ self.assertIn("text/html", response.getheader("Content-Type", ""))
+ self.assertIn("Set up your site", body)
+ self.assertIn('action="/install"', body)
+ self.assertIn('name="csrf_token"', body)
+ self.assertIn("Scriptorium", body)
+ self.assertIn(
+ f"Scriptorium is running at http://127.0.0.1:{expected_port}", diagnostic
+ )
+ self.assertIn(
+ f"First run — open http://127.0.0.1:{expected_port}/install", diagnostic
+ )
+ return
+ finally:
+ connection.close()
+ time.sleep(0.1)
+ self.fail(
+ f"No installer response on configured port {expected_port} within "
+ f"{STARTUP_SECONDS}s ({last_error}). Server output:\n{self.server_log()}"
+ )
+
+ def test_configured_port_serves_installer_and_updates_startup_urls(self):
+ selected = self.available_port()
+ self.assertNotEqual(selected, 8080, "OS ephemeral range must exclude default port 8080")
+ self.assert_installer(selected, f"web_server_port = {selected}\n")
+
+ def test_missing_port_setting_defaults_to_8080(self):
+ self.assert_installer(8080, "# web_server_port intentionally omitted\n")
+
+ def test_missing_config_file_defaults_to_8080(self):
+ self.assert_installer(8080, None)
+
+
+if __name__ == "__main__":
+ unittest.main()