diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 6d493204..29de7b72 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -183,6 +183,7 @@ export default defineConfig({
{ text: "branding", link: "/modules/branding" },
{ text: "background_tasks", link: "/modules/background_tasks" },
{ text: "audit_log", link: "/modules/audit_log" },
+ { text: "site_lock", link: "/modules/site_lock" },
{ text: "dashboard", link: "/modules/dashboard" },
],
},
diff --git a/docs/index.md b/docs/index.md
index 164799f2..a7fda0e4 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -26,7 +26,7 @@ features:
link: /guide/first-module
linkText: Build a module
- title: Use a bundled module
- details: Ten first-party modules ship with the framework — auth, users, keycloak, permissions, settings, file_storage, background_tasks, feature_flags, audit_log, dashboard.
+ details: Eleven first-party modules ship with the framework — auth, users, keycloak, permissions, settings, file_storage, background_tasks, feature_flags, audit_log, dashboard, site_lock.
link: /modules/
linkText: Browse modules
- title: Operate it in production
diff --git a/docs/modules/index.md b/docs/modules/index.md
index a7345be4..c8fce364 100644
--- a/docs/modules/index.md
+++ b/docs/modules/index.md
@@ -15,6 +15,7 @@ simple_module_python ships with eleven first-party modules. Each is a regular Py
| [`background_tasks`](/modules/background_tasks) | `users` | Celery + Redis workers, persistent task history, retry, stuck-task sweep, live worker dashboard. |
| [`audit_log`](/modules/audit_log) | `users` | Automatic field-level audit trail for SQLModel entities, with an admin UI to browse change history. |
| [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). |
+| [`site_lock`](/modules/site_lock) | `settings`, `auth` | Optional site-wide shared-password gate for staging / pre-launch sites. Off by default. |
## How modules are wired in
diff --git a/docs/modules/site_lock.md b/docs/modules/site_lock.md
new file mode 100644
index 00000000..e297bc9d
--- /dev/null
+++ b/docs/modules/site_lock.md
@@ -0,0 +1,78 @@
+# site_lock
+
+Optional site-wide password gate — a staging / pre-launch door. When enabled, every visitor must enter one shared password before they can see anything: not the landing page, not the API, not even that a login form exists.
+
+**Off by default.** Installing the module changes nothing until an operator turns it on.
+
+This is not user authentication — that is what [`auth`](/modules/auth), [`users`](/modules/users), and [`permissions`](/modules/permissions) are for. The site lock is one shared secret in front of everything, with no notion of identity.
+
+## ModuleMeta
+
+| Field | Value |
+|---|---|
+| `name` | `SiteLock` |
+| `route_prefix` | *(none)* |
+| `view_prefix` | *(none)* |
+| `depends_on` | `["Settings", "Auth"]` |
+
+Both dependencies are load-bearing. `Settings` lets `register_module_settings` reach the module registry. `Auth` makes this module sort *after* the auth module, and since middleware is installed in topological order while Starlette's `add_middleware` is LIFO, sorting later means wrapping **outermost** — so `SiteLockMiddleware` executes *before* `AuthMiddleware`.
+
+That ordering is the point of the design: an anonymous visitor gets the gate rather than a redirect to the login page, so a locked site never reveals it has one. The order is pinned by `framework/hosting/tests/test_middleware_order.py`.
+
+## Routes
+
+The module registers **no routes**. The gate is served entirely from middleware at `/__unlock`, which keeps it reachable before auth runs and means a locked site serves exactly one document — no menus, no branding, no JS bundle. The gate keeps working even if the frontend build is broken.
+
+| Method + path | Response |
+|---|---|
+| `GET /__unlock` | The gate page (200) |
+| `POST /__unlock` | 303 to `next` on success; 401 on a wrong password; 429 when rate-limited |
+| Other methods on `/__unlock` | 405 |
+
+## Settings
+
+DB-backed and hot-reloadable — changes apply immediately, no restart.
+
+| Field | Type | Default | Meaning |
+|---|---|---|---|
+| `enabled` | `bool` | `false` | Master switch |
+| `password` | `str` | `""` | The shared password. Masked in the admin UI |
+| `message` | `str` | `""` | Optional line shown on the gate page |
+
+Enabling with a blank password is rejected by a model validator, so the settings screen shows an error rather than gating the site behind the empty string.
+
+## Behaviour when locked
+
+| Request | Result |
+|---|---|
+| `/health*` | **Always passes.** Gating it would fail Kubernetes probes and get the pod killed |
+| `/__unlock` | The gate page |
+| `/api/*`, or any request with an `Authorization` header | `403 {"detail": "Site is locked"}` |
+| Anything else | `302` to `/__unlock?next=…` |
+
+A `403` rather than `401` is deliberate: a `401` would invite an auth flow that cannot succeed while the site is locked. Every gated response carries `Cache-Control: no-store`.
+
+Unlock state lives in the signed session cookie. The stored marker is a fingerprint of the current password, so **rotating the password immediately invalidates every unlocked session**.
+
+## Admin bypass and the lockout it does not cover
+
+A user already holding a session with the `admin` role skips the gate. This is the intended escape hatch: enable the gate, mistype the password, and you are still holding the session that lets you go back to Settings and fix it. The bypass stamps the session marker on first use, so the provider lookup costs once per session rather than once per request.
+
+**It only rescues a live session.** If no admin is signed in and the password has been forgotten, there is no in-app recovery — clear the override directly:
+
+```sql
+DELETE FROM settings_setting
+ WHERE scope = 'system' AND key = 'site_lock.enabled';
+```
+
+Then restart the app (or save any setting) so the module re-hydrates.
+
+## Brute-force protection
+
+The unlock endpoint tracks failures per client IP in memory: 10 failures within 5 minutes trigger a 15-minute cooldown returning `429`. These thresholds are module constants, not settings — they are the only defence on a single shared secret, so they are not an operator-tunable surface.
+
+The limiter is process-local, which suits the single-process staging deployments this module targets. It lives on the module state rather than inside the settings object, so an unrelated settings save cannot clear an in-flight cooldown.
+
+## Open-redirect protection
+
+The `next` target is sanitised before being echoed into the form or used in a redirect. Only same-site absolute paths are accepted; protocol-relative (`//host`), backslash-prefixed (`/\host`), and CR/LF-carrying values all fall back to `/`.
diff --git a/docs/superpowers/plans/2026-08-05-site-lock.md b/docs/superpowers/plans/2026-08-05-site-lock.md
new file mode 100644
index 00000000..f0dbc62e
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-05-site-lock.md
@@ -0,0 +1,1353 @@
+# Site Lock Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship a `site_lock` module that hides the entire site behind one shared password — a staging / pre-launch gate — off by default.
+
+**Architecture:** A single raw-ASGI middleware installed by a new module that declares `depends_on=["Settings", "Auth"]`. Because the host installs module middleware in topological order and Starlette's `add_middleware` is LIFO, sorting after `Auth` makes SiteLock wrap outermost and execute *before* `AuthMiddleware` — so an anonymous visitor sees the gate rather than a redirect to the login page. Configuration is DB-backed through `register_module_settings`; unlock state lives in the signed session cookie.
+
+**Tech Stack:** Python 3.12, FastAPI/Starlette, pydantic-settings, pytest (`asyncio_mode=auto`), httpx `ASGITransport`, uv workspaces.
+
+**Spec:** `docs/superpowers/specs/2026-08-05-site-lock-design.md`
+
+## Global Constraints
+
+- **300-line cap** on every `.py` file, enforced by `scripts/check_file_size.py`.
+- **SQLModel is the project-wide model standard** — not relevant here (this module has no DB models and adds no migration), but do not introduce `pydantic.BaseModel`. `SiteLockSettings` subclasses `pydantic_settings.BaseSettings`, matching every other module's settings class.
+- **Raw ASGI middleware only** — never `BaseHTTPMiddleware`, per `framework/hosting/simple_module_hosting/middleware.py`.
+- **Package version `0.0.26`**, and workspace deps pinned `==0.0.26`, matching every other module in this repo at this commit.
+- **Module name is `SiteLock`; package/settings key is `site_lock`.** These exact strings appear in `app.state.site_lock`, the settings registry key, and `ModuleMeta.name`.
+- **Never import from `users`** — `site_lock` must work under the `keycloak` auth provider too. The admin role name is duplicated as a local constant on purpose.
+- `make lint` runs Ruff format-check, Ruff, `ty`, Biome, `tsc`, and the file-size cap. `make test-py` runs pytest.
+- Settings fields whose name matches `(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)` are auto-masked in the admin UI. The field **must** be named `password` to get this for free.
+
+---
+
+## File Structure
+
+**New package — `modules/site_lock/`:**
+
+| File | Responsibility |
+|---|---|
+| `pyproject.toml` | Package metadata + `simple_module` entry point |
+| `README.md` | Usage, and the documented cold-start lockout limitation |
+| `site_lock/__init__.py` | Empty marker |
+| `site_lock/py.typed` | Typing marker |
+| `site_lock/constants.py` | Every literal: paths, session key, admin role, rate-limit thresholds |
+| `site_lock/settings.py` | `SiteLockSettings` + the enabled-requires-password validator |
+| `site_lock/state.py` | `SiteLockState` mounted at `app.state.site_lock` |
+| `site_lock/rate_limit.py` | `AttemptLimiter` — in-memory per-IP failure tracking |
+| `site_lock/page.py` | `safe_next()` + `render_unlock_page()` |
+| `site_lock/templates/unlock.html` | Self-contained gate page, inlined CSS |
+| `site_lock/middleware.py` | `SiteLockMiddleware` — the gate itself |
+| `site_lock/module.py` | `SiteLockModule` wiring settings + middleware |
+| `tests/test_*.py` | One test file per unit above |
+
+**Modified:**
+
+| File | Change |
+|---|---|
+| `pyproject.toml` | Add `modules/site_lock` to workspace members and `modules/site_lock/tests` to `testpaths` |
+| `host/pyproject.toml` | Add `simple_module_site_lock` to dependencies and `[tool.uv.sources]` |
+| `framework/hosting/tests/test_middleware_order.py` | Insert `"SiteLockMiddleware"` into both expected tuples |
+| `framework/cli/simple_module_cli/catalog.py` | Add the `site_lock` catalog entry |
+| `docs/modules/site_lock.md`, `docs/.vitepress/config.ts`, `docs/index.md` | Docs page + nav |
+
+Splitting `page.py` out of `middleware.py` keeps both well under the 300-line cap and lets the escaping/`next`-sanitisation logic be unit-tested without an ASGI harness.
+
+---
+
+### Task 1: Package scaffold + settings
+
+**Files:**
+- Create: `modules/site_lock/pyproject.toml`, `modules/site_lock/README.md`, `modules/site_lock/site_lock/__init__.py`, `modules/site_lock/site_lock/py.typed`, `modules/site_lock/site_lock/constants.py`, `modules/site_lock/site_lock/settings.py`, `modules/site_lock/site_lock/state.py`
+- Modify: `pyproject.toml` (workspace members + testpaths)
+- Test: `modules/site_lock/tests/test_settings.py`
+
+**Interfaces:**
+- Produces: `SiteLockSettings(enabled: bool, password: str, message: str)`; `SiteLockState(settings, limiter)`; constants module `site_lock.constants`.
+
+- [ ] **Step 1: Create the package skeleton**
+
+`modules/site_lock/pyproject.toml`:
+
+```toml
+[project]
+name = "simple_module_site_lock"
+version = "0.0.26"
+description = "Optional site-wide password gate (staging / pre-launch) for simple_module apps"
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.12"
+authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
+keywords = ["simple-module", "password", "staging", "gate"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Framework :: FastAPI",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP",
+ "Typing :: Typed",
+]
+dependencies = [
+ "simple_module_core==0.0.26",
+ "simple_module_hosting==0.0.26",
+ "simple_module_settings==0.0.26",
+ "simple_module_auth==0.0.26",
+]
+
+[project.entry-points.simple_module]
+site_lock = "site_lock.module:SiteLockModule"
+
+[project.urls]
+Homepage = "https://github.com/antosubash/simple_module_python"
+Repository = "https://github.com/antosubash/simple_module_python"
+Issues = "https://github.com/antosubash/simple_module_python/issues"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["site_lock"]
+
+[tool.uv.sources]
+simple_module_core = { workspace = true }
+simple_module_hosting = { workspace = true }
+simple_module_settings = { workspace = true }
+simple_module_auth = { workspace = true }
+```
+
+Create empty `site_lock/__init__.py` and `site_lock/py.typed`.
+
+- [ ] **Step 2: Register the package in the workspace**
+
+In the root `pyproject.toml`, add `"modules/site_lock",` to `[tool.uv.workspace] members` (after `"modules/audit_log",`), and append `"modules/site_lock/tests"` to `[tool.pytest.ini_options] testpaths`.
+
+Then run: `uv sync --all-packages`
+Expected: `simple_module_site_lock` installs as an editable workspace package.
+
+- [ ] **Step 3: Write the failing settings test**
+
+`modules/site_lock/tests/test_settings.py`:
+
+```python
+"""SiteLockSettings defaults and the enabled-requires-password guard."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+from site_lock.settings import SiteLockSettings
+
+
+def test_disabled_by_default() -> None:
+ s = SiteLockSettings()
+ assert s.enabled is False
+ assert s.password == ""
+ assert s.message == ""
+
+
+def test_enabled_with_password_is_valid() -> None:
+ s = SiteLockSettings(enabled=True, password="hunter2")
+ assert s.enabled is True
+
+
+@pytest.mark.parametrize("password", ["", " "])
+def test_enabled_without_password_is_rejected(password: str) -> None:
+ with pytest.raises(ValidationError):
+ SiteLockSettings(enabled=True, password=password)
+
+
+def test_password_field_is_masked_by_settings_ui() -> None:
+ # The admin UI masks fields by name; `password` must match that regex.
+ from settings._module_settings import is_secret_field
+
+ assert is_secret_field("password") is True
+```
+
+- [ ] **Step 4: Run it and watch it fail**
+
+Run: `uv run pytest modules/site_lock/tests/test_settings.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'site_lock.settings'`
+
+- [ ] **Step 5: Write constants, settings, and state**
+
+`site_lock/constants.py`:
+
+```python
+"""Every literal the site_lock module depends on."""
+
+from __future__ import annotations
+
+MODULE_NAME = "SiteLock"
+MODULE_PACKAGE = "site_lock"
+
+# Dependencies — names must match the other modules' ``ModuleMeta.name``.
+MODULE_SETTINGS = "Settings"
+MODULE_AUTH = "Auth"
+
+# The gate endpoint. Double-underscore prefix keeps it clear of app routes.
+UNLOCK_PATH = "/__unlock"
+SESSION_KEY = "site_lock"
+
+# Never gated: Kubernetes liveness/readiness probes must always succeed.
+HEALTH_PREFIX = "/health"
+API_PREFIX = "/api/"
+
+# Duplicated rather than imported from ``users`` — site_lock must also work
+# under the ``keycloak`` provider, which has no dependency on ``users``.
+ADMIN_ROLE = "admin"
+
+# Brute-force limits. Deliberately NOT settings fields: they are the only
+# defence on a single shared secret and are not an operator-tunable surface.
+MAX_FAILURES = 10
+WINDOW_SECONDS = 300
+COOLDOWN_SECONDS = 900
+```
+
+`site_lock/settings.py`:
+
+```python
+"""Site Lock module settings — DB-backed via ``register_module_settings``.
+
+The field is named ``password`` so the settings admin UI masks it
+automatically (``settings._module_settings.is_secret_field``).
+"""
+
+from __future__ import annotations
+
+from pydantic import model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class SiteLockSettings(BaseSettings):
+ """Site-wide shared-password gate configuration."""
+
+ model_config = SettingsConfigDict(extra="ignore")
+
+ enabled: bool = False
+ password: str = ""
+ message: str = ""
+
+ @model_validator(mode="after")
+ def _password_required_when_enabled(self) -> SiteLockSettings:
+ """Refuse to gate the site behind an empty password.
+
+ Without this, flipping ``enabled`` on before setting a password would
+ lock every visitor out from behind a secret that is the empty string.
+ Raising here makes ``apply_changes_and_reload`` reject the change so
+ the settings screen shows a validation error instead.
+ """
+ if self.enabled and not self.password.strip():
+ raise ValueError("password must be set before enabling the site lock")
+ return self
+```
+
+`site_lock/state.py`:
+
+```python
+"""Module-owned state attached to ``app.state.site_lock``."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from site_lock import constants as c
+from site_lock.rate_limit import AttemptLimiter
+from site_lock.settings import SiteLockSettings
+
+
+def _default_limiter() -> AttemptLimiter:
+ return AttemptLimiter(
+ max_failures=c.MAX_FAILURES,
+ window_seconds=c.WINDOW_SECONDS,
+ cooldown_seconds=c.COOLDOWN_SECONDS,
+ )
+
+
+@dataclass
+class SiteLockState:
+ """Per-app site-lock state.
+
+ ``settings`` is reassigned in place by ``settings.reload`` on a hot
+ reload, so this dataclass must stay mutable. ``limiter`` survives those
+ reloads, which is what keeps an in-flight brute-force cooldown from being
+ cleared by an unrelated settings save.
+ """
+
+ settings: SiteLockSettings
+ limiter: AttemptLimiter = field(default_factory=_default_limiter)
+
+
+__all__ = ["SiteLockState"]
+```
+
+Note `state.py` imports `AttemptLimiter`, which Task 2 creates. Write `rate_limit.py` first if you are executing strictly top-to-bottom — or accept that `test_settings.py` is the only thing that must pass at the end of this task, and it does not import `state`.
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `uv run pytest modules/site_lock/tests/test_settings.py -v`
+Expected: PASS (4 tests, one parametrized twice → 5 cases)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add modules/site_lock pyproject.toml
+git commit -m "feat(site_lock): package scaffold and DB-backed settings"
+```
+
+---
+
+### Task 2: Attempt limiter
+
+**Files:**
+- Create: `modules/site_lock/site_lock/rate_limit.py`
+- Test: `modules/site_lock/tests/test_rate_limit.py`
+
+**Interfaces:**
+- Produces: `AttemptLimiter(max_failures: int, window_seconds: int, cooldown_seconds: int)` with `is_blocked(key: str, *, now: float | None = None) -> bool`, `record_failure(key: str, *, now: float | None = None) -> None`, `reset(key: str) -> None`. The injectable `now` is what lets tests cover expiry without sleeping.
+
+- [ ] **Step 1: Write the failing test**
+
+`modules/site_lock/tests/test_rate_limit.py`:
+
+```python
+"""In-memory per-IP attempt limiter."""
+
+from __future__ import annotations
+
+from site_lock.rate_limit import AttemptLimiter
+
+
+def _limiter() -> AttemptLimiter:
+ return AttemptLimiter(max_failures=3, window_seconds=100, cooldown_seconds=500)
+
+
+def test_fresh_key_is_not_blocked() -> None:
+ assert _limiter().is_blocked("1.2.3.4", now=0.0) is False
+
+
+def test_blocks_after_max_failures() -> None:
+ lim = _limiter()
+ for i in range(3):
+ lim.record_failure("1.2.3.4", now=float(i))
+ assert lim.is_blocked("1.2.3.4", now=3.0) is True
+
+
+def test_under_the_limit_is_not_blocked() -> None:
+ lim = _limiter()
+ for i in range(2):
+ lim.record_failure("1.2.3.4", now=float(i))
+ assert lim.is_blocked("1.2.3.4", now=3.0) is False
+
+
+def test_failures_outside_the_window_do_not_accumulate() -> None:
+ lim = _limiter()
+ lim.record_failure("1.2.3.4", now=0.0)
+ lim.record_failure("1.2.3.4", now=1.0)
+ # 500s later the first two have aged out of the 100s window.
+ lim.record_failure("1.2.3.4", now=500.0)
+ assert lim.is_blocked("1.2.3.4", now=500.0) is False
+
+
+def test_cooldown_expires() -> None:
+ lim = _limiter()
+ for i in range(3):
+ lim.record_failure("1.2.3.4", now=float(i))
+ assert lim.is_blocked("1.2.3.4", now=3.0) is True
+ assert lim.is_blocked("1.2.3.4", now=600.0) is False
+
+
+def test_keys_are_independent() -> None:
+ lim = _limiter()
+ for i in range(3):
+ lim.record_failure("1.1.1.1", now=float(i))
+ assert lim.is_blocked("1.1.1.1", now=3.0) is True
+ assert lim.is_blocked("2.2.2.2", now=3.0) is False
+
+
+def test_reset_clears_a_block() -> None:
+ lim = _limiter()
+ for i in range(3):
+ lim.record_failure("1.2.3.4", now=float(i))
+ lim.reset("1.2.3.4")
+ assert lim.is_blocked("1.2.3.4", now=3.0) is False
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `uv run pytest modules/site_lock/tests/test_rate_limit.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'site_lock.rate_limit'`
+
+- [ ] **Step 3: Implement**
+
+`site_lock/rate_limit.py`:
+
+```python
+"""In-memory per-IP attempt limiter for the unlock endpoint.
+
+Deliberately process-local: it mirrors the existing ``users``
+``LoginRateLimiter`` and is adequate for the single-process staging
+deployments this module targets. ``users``' version is not reused because
+importing it would couple ``site_lock`` to the ``users`` module, and the
+site lock must also work under the ``keycloak`` provider.
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+
+
+@dataclass
+class _Bucket:
+ failures: list[float] = field(default_factory=list)
+ blocked_until: float = 0.0
+
+
+class AttemptLimiter:
+ """Track failed unlock attempts per client key and impose a cooldown."""
+
+ def __init__(
+ self,
+ *,
+ max_failures: int,
+ window_seconds: int,
+ cooldown_seconds: int,
+ ) -> None:
+ self._max = max_failures
+ self._window = window_seconds
+ self._cooldown = cooldown_seconds
+ self._buckets: dict[str, _Bucket] = {}
+
+ @staticmethod
+ def _now(now: float | None) -> float:
+ return time.monotonic() if now is None else now
+
+ def is_blocked(self, key: str, *, now: float | None = None) -> bool:
+ bucket = self._buckets.get(key)
+ return bucket is not None and bucket.blocked_until > self._now(now)
+
+ def record_failure(self, key: str, *, now: float | None = None) -> None:
+ moment = self._now(now)
+ bucket = self._buckets.setdefault(key, _Bucket())
+ bucket.failures = [t for t in bucket.failures if moment - t < self._window]
+ bucket.failures.append(moment)
+ if len(bucket.failures) >= self._max:
+ bucket.blocked_until = moment + self._cooldown
+ bucket.failures.clear()
+
+ def reset(self, key: str) -> None:
+ self._buckets.pop(key, None)
+
+
+__all__ = ["AttemptLimiter"]
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `uv run pytest modules/site_lock/tests/test_rate_limit.py -v`
+Expected: PASS (7 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add modules/site_lock/site_lock/rate_limit.py modules/site_lock/tests/test_rate_limit.py
+git commit -m "feat(site_lock): in-memory per-IP attempt limiter"
+```
+
+---
+
+### Task 3: Gate page — template, escaping, and `next` sanitisation
+
+**Files:**
+- Create: `modules/site_lock/site_lock/templates/unlock.html`, `modules/site_lock/site_lock/page.py`
+- Test: `modules/site_lock/tests/test_page.py`
+
+**Interfaces:**
+- Produces: `safe_next(raw: str | None) -> str` and `render_unlock_page(*, message: str = "", error: str = "", next_url: str = "/") -> str`.
+
+- [ ] **Step 1: Write the failing test**
+
+`modules/site_lock/tests/test_page.py`:
+
+```python
+"""Gate page rendering, escaping, and next-target sanitisation."""
+
+from __future__ import annotations
+
+import pytest
+from site_lock.page import render_unlock_page, safe_next
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ None,
+ "",
+ "//evil.example/path",
+ "https://evil.example",
+ "http://evil.example",
+ "/\\evil.example",
+ "evil",
+ "/ok\r\nSet-Cookie: x=1",
+ ],
+)
+def test_safe_next_rejects_offsite_and_injection(raw: str | None) -> None:
+ assert safe_next(raw) == "/"
+
+
+@pytest.mark.parametrize("raw", ["/", "/dashboard/", "/users/me"])
+def test_safe_next_allows_same_site_paths(raw: str) -> None:
+ assert safe_next(raw) == raw
+
+
+def test_message_is_html_escaped() -> None:
+ html = render_unlock_page(message="")
+ assert "" not in html
+ assert "<script>" in html
+
+
+def test_error_is_rendered_and_escaped() -> None:
+ html = render_unlock_page(error="Bad password")
+ assert "<b>" in html
+ assert "password" not in html
+
+
+def test_no_error_block_when_no_error() -> None:
+ assert 'role="alert"' not in render_unlock_page()
+
+
+def test_next_is_embedded_as_a_hidden_field() -> None:
+ html = render_unlock_page(next_url="/dashboard/")
+ assert 'name="next"' in html
+ assert "/dashboard/" in html
+
+
+def test_offsite_next_is_neutralised_in_the_form() -> None:
+ html = render_unlock_page(next_url="//evil.example")
+ assert "evil.example" not in html
+
+
+def test_page_is_self_contained() -> None:
+ html = render_unlock_page()
+ # No external assets: a locked site must serve exactly one document.
+ assert "")
+ assert "" not in html
+ assert "<script>" in html
+
+
+def test_error_is_rendered_and_escaped() -> None:
+ html = render_unlock_page(error="Bad password")
+ assert "<b>" in html
+ assert "password" not in html
+
+
+def test_no_error_block_when_no_error() -> None:
+ assert 'role="alert"' not in render_unlock_page()
+
+
+def test_next_is_embedded_as_a_hidden_field() -> None:
+ html = render_unlock_page(next_url="/dashboard/")
+ assert 'name="next"' in html
+ assert "/dashboard/" in html
+
+
+def test_offsite_next_is_neutralised_in_the_form() -> None:
+ html = render_unlock_page(next_url="//evil.example")
+ assert "evil.example" not in html
+
+
+def test_page_is_self_contained() -> None:
+ html = render_unlock_page()
+ # No external assets: a locked site must serve exactly one document.
+ assert "