BareGit

Initial commit: Overseer v1 with inventory module

Self-hosted family information system in C++23. Ships the inventory
module end-to-end: storages (tree), stuffs, move history, FTS5 search,
photo attachments transcoded to AVIF, OIDC login, htmx-driven UI with
inline name edit and move-status fragments. 43 GoogleTest cases cover
the repo, migrations, and HTTP integration.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat May 16 12:23:21 2026 -0700
Commit: c3e61008ebecb33b9dd15c9360acc2f5eca38dde

Changes

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d1a4706
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+build/
+.cache/
+.claude/
+
+# Local dev config carries a real OIDC client_secret.
+overseer.local.yaml
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..304f8f7
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,225 @@
+# cmake -B build . && cmake --build build -j
+cmake_minimum_required(VERSION 3.24)
+
+set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)
+set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
+
+project(Overseer)
+
+option(OVERSEER_BUILD_TEST "Build unit tests." OFF)
+
+include(FetchContent)
+
+FetchContent_Declare(
+    libmw
+    GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+)
+FetchContent_Declare(
+    json
+    GIT_REPOSITORY https://github.com/nlohmann/json.git
+    GIT_TAG v3.11.3
+)
+FetchContent_Declare(
+    inja
+    GIT_REPOSITORY https://github.com/pantor/inja.git
+    GIT_TAG main
+)
+FetchContent_Declare(
+    spdlog
+    GIT_REPOSITORY https://github.com/gabime/spdlog.git
+    GIT_TAG v1.12.0
+)
+FetchContent_Declare(
+    yaml-cpp
+    GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git
+    GIT_TAG master           # 0.8.0 misses <cstdint> include; broken on GCC 15
+)
+FetchContent_Declare(
+    cxxopts
+    GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git
+    GIT_TAG v3.1.1
+)
+if(OVERSEER_BUILD_TEST)
+    FetchContent_Declare(
+        googletest
+        URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz
+    )
+endif()
+
+set(LIBMW_BUILD_URL ON)
+set(LIBMW_BUILD_SQLITE ON)
+set(LIBMW_BUILD_HTTP_SERVER ON)
+set(LIBMW_BUILD_CRYPTO ON)
+set(SPDLOG_USE_STD_FORMAT ON)
+set(INJA_USE_EMBEDDED_JSON FALSE)
+set(INJA_BUILD_TESTS FALSE)
+set(BUILD_BENCHMARK FALSE)
+set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "")
+set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "")
+set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "")
+
+# json must be visible to inja
+include_directories(BEFORE SYSTEM)
+FetchContent_MakeAvailable(libmw json inja spdlog yaml-cpp cxxopts)
+if(OVERSEER_BUILD_TEST)
+    FetchContent_MakeAvailable(googletest)
+endif()
+
+find_package(CURL REQUIRED)
+find_package(SQLite3 REQUIRED)
+
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(Magick REQUIRED IMPORTED_TARGET Magick++)
+pkg_check_modules(Systemd REQUIRED IMPORTED_TARGET libsystemd)
+
+# ---- Common include dirs / libs ----------------------------------------
+
+set(OVERSEER_COMMON_INCLUDES
+    ${libmw_SOURCE_DIR}/includes
+    ${CURL_INCLUDE_DIR}
+    ${json_SOURCE_DIR}/single_include
+    ${inja_SOURCE_DIR}/single_include/inja
+    ${SQLite3_INCLUDE_DIRS}
+)
+
+set(OVERSEER_COMMON_LIBS
+    mw::mw
+    mw::url
+    mw::sqlite
+    mw::http-server
+    mw::crypto
+    spdlog::spdlog
+    yaml-cpp::yaml-cpp
+    ${CURL_LIBRARIES}
+    ${SQLite3_LIBRARIES}
+)
+
+# ---- liboverseer_inventory --------------------------------------------
+
+set(INVENTORY_SOURCES
+    src/inventory/src/storage.cpp
+    src/inventory/src/stuff.cpp
+    src/inventory/src/search.cpp
+    src/inventory/src/attachment.cpp
+    src/inventory/src/repo.cpp
+)
+
+add_library(overseer_inventory STATIC ${INVENTORY_SOURCES})
+set_property(TARGET overseer_inventory PROPERTY CXX_STANDARD 23)
+set_property(TARGET overseer_inventory PROPERTY CXX_EXTENSIONS OFF)
+target_compile_options(overseer_inventory PRIVATE
+    -Wall -Wextra -Wpedantic)
+target_include_directories(overseer_inventory
+    PUBLIC src/inventory/include
+    PRIVATE ${OVERSEER_COMMON_INCLUDES})
+target_link_libraries(overseer_inventory PUBLIC
+    ${OVERSEER_COMMON_LIBS}
+    PkgConfig::Magick)
+
+# ---- overseer_db ------------------------------------------------------
+
+set(DB_SOURCES
+    src/db/migrations.cpp
+    src/db/migration_001_init.cpp
+    src/db/backup.cpp
+)
+
+add_library(overseer_db STATIC ${DB_SOURCES})
+set_property(TARGET overseer_db PROPERTY CXX_STANDARD 23)
+set_property(TARGET overseer_db PROPERTY CXX_EXTENSIONS OFF)
+target_compile_options(overseer_db PRIVATE
+    -Wall -Wextra -Wpedantic)
+target_include_directories(overseer_db
+    PUBLIC src
+    PRIVATE ${OVERSEER_COMMON_INCLUDES})
+target_link_libraries(overseer_db PUBLIC ${OVERSEER_COMMON_LIBS})
+
+# ---- overseer (binary) ------------------------------------------------
+
+set(OVERSEER_SOURCES
+    src/overseer/main.cpp
+    src/overseer/config.cpp
+    src/overseer/render.cpp
+    src/overseer/session.cpp
+    src/overseer/auth_handler.cpp
+    src/overseer/attachment_handler.cpp
+    src/overseer/server.cpp
+    src/overseer/modules/inventory/routes.cpp
+    src/overseer/modules/inventory/views.cpp
+)
+
+add_executable(overseer ${OVERSEER_SOURCES})
+set_property(TARGET overseer PROPERTY CXX_STANDARD 23)
+set_property(TARGET overseer PROPERTY CXX_EXTENSIONS OFF)
+target_compile_options(overseer PRIVATE
+    -Wall -Wextra -Wpedantic)
+target_include_directories(overseer PRIVATE
+    src
+    ${OVERSEER_COMMON_INCLUDES})
+target_link_libraries(overseer PRIVATE
+    overseer_inventory
+    overseer_db
+    cxxopts
+    PkgConfig::Magick
+    PkgConfig::Systemd)
+
+# Where the runtime data lives during dev:
+target_compile_definitions(overseer PRIVATE
+    OVERSEER_DEFAULT_TEMPLATE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/src/overseer/modules"
+    OVERSEER_DEFAULT_STATIC_DIR="${CMAKE_CURRENT_SOURCE_DIR}/src/static")
+
+# ---- tests ------------------------------------------------------------
+
+if(OVERSEER_BUILD_TEST)
+    # Server / route code reused by the HTTP integration tests. We
+    # rebuild just enough of the binary's source so the test binary
+    # links cleanly without dragging in main().
+    add_library(overseer_server STATIC
+        src/overseer/config.cpp
+        src/overseer/render.cpp
+        src/overseer/session.cpp
+        src/overseer/auth_handler.cpp
+        src/overseer/attachment_handler.cpp
+        src/overseer/server.cpp
+        src/overseer/modules/inventory/routes.cpp
+        src/overseer/modules/inventory/views.cpp)
+    set_property(TARGET overseer_server PROPERTY CXX_STANDARD 23)
+    set_property(TARGET overseer_server PROPERTY CXX_EXTENSIONS OFF)
+    target_compile_options(overseer_server PRIVATE
+        -Wall -Wextra -Wpedantic)
+    target_include_directories(overseer_server PUBLIC
+        src
+        ${OVERSEER_COMMON_INCLUDES})
+    target_link_libraries(overseer_server PUBLIC
+        overseer_inventory overseer_db
+        PkgConfig::Magick
+        PkgConfig::Systemd)
+    target_compile_definitions(overseer_server PRIVATE
+        OVERSEER_DEFAULT_TEMPLATE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/src/overseer/modules"
+        OVERSEER_DEFAULT_STATIC_DIR="${CMAKE_CURRENT_SOURCE_DIR}/src/static")
+
+    set(TEST_FILES
+        tests/test_inventory.cpp
+        tests/test_migrations.cpp
+        tests/test_http.cpp
+        tests/support/seed.cpp
+    )
+    add_executable(overseer_test ${TEST_FILES})
+    set_property(TARGET overseer_test PROPERTY CXX_STANDARD 23)
+    target_include_directories(overseer_test PRIVATE
+        src
+        tests
+        ${OVERSEER_COMMON_INCLUDES}
+        ${googletest_SOURCE_DIR}/googletest/include)
+    target_link_libraries(overseer_test PRIVATE
+        overseer_server overseer_inventory overseer_db
+        GTest::gtest_main GTest::gmock_main)
+    target_compile_definitions(overseer_test PRIVATE
+        OVERSEER_TEST_TEMPLATE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/src/overseer/modules"
+        OVERSEER_TEST_STATIC_DIR="${CMAKE_CURRENT_SOURCE_DIR}/src/static")
+
+    enable_testing()
+    include(GoogleTest)
+    gtest_discover_tests(overseer_test
+        WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
+endif()
diff --git a/designs/design-0-architecture.md b/designs/design-0-architecture.md
new file mode 100644
index 0000000..45fabb0
--- /dev/null
+++ b/designs/design-0-architecture.md
@@ -0,0 +1,1372 @@
+# Overseer — System Design Document
+
+---
+
+## 0. How to read this document
+
+This document is the authoritative engineering plan for the first
+release of **Overseer**, the family information system described in
+`prd.md`. It is written so that a brand-new engineer can sit down,
+read top to bottom, and start implementing without having to ask
+clarifying questions. Each section explains *what* we are building,
+*why* the choice was made, and *how* to implement it.
+
+Wherever a section uses unfamiliar jargon, follow the embedded links
+to the official docs:
+
+- [SQLite documentation](https://www.sqlite.org/docs.html)
+- [Inja template engine](https://github.com/pantor/inja)
+- [htmx documentation](https://htmx.org/docs/)
+- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
+- [Magick++ tutorial](https://imagemagick.org/Magick++/tutorial/Magick++_tutorial.pdf)
+- [spdlog](https://github.com/gabime/spdlog)
+- [yaml-cpp](https://github.com/jbeder/yaml-cpp)
+- [cpp-httplib](https://github.com/yhirose/cpp-httplib) (used internally by `mw::HTTPServer`)
+
+---
+
+## 1. Goals and non-goals
+
+### 1.1 Goals
+
+1. Provide a **single self-hosted web service** for one family to
+   record, find, and edit information about household items. The
+   first module is **inventory** (storages + stuffs).
+2. Be **modular**: a future *Doc* module must be addable without
+   re-architecting authentication, configuration, logging, or
+   templating.
+3. Be **boring and reliable**: one SQLite file, one binary, one
+   YAML config, one systemd unit. No microservices, no message
+   queues, no JS build pipeline.
+4. Keep the **client trivial**: server-rendered HTML plus htmx for
+   sprinklings of interactivity. No SPA framework.
+5. Keep the **business logic reusable**: inventory logic must live
+   in a separate library so future surfaces (MCP, REST for agents)
+   can call it directly.
+
+### 1.2 Non-goals (explicitly out of scope for v1)
+
+- Per-row ACLs. Any authenticated user can read or modify any row.
+- Agent / MCP / REST surface. Library is structured to allow it
+  later, but no endpoints are exposed in v1.
+- Multi-family / multi-tenant operation. One process serves one
+  family.
+- High availability / replication. The DB is a single file.
+- Mobile native clients. The web UI is responsive HTML.
+- The *Doc* module. Schemas and routes for it are not built; only
+  hooks for future modules are.
+
+### 1.3 Success criteria
+
+A release of v1 is considered done when:
+
+1. A logged-in user can create a nested tree of storages and stuffs.
+2. Each storage and stuff has a stable, shareable URL.
+3. Uploading a photo produces an AVIF ≤1024px-long-side stored
+   exactly once even if uploaded multiple times.
+4. Search across storages and stuffs returns results in <100 ms for
+   a corpus of 10 000 rows (very generous given the scale).
+5. Restarting the binary on an older DB triggers migration and a
+   backup; restarting on a newer DB refuses to start.
+
+---
+
+## 2. High-level architecture
+
+```
+                +-----------------------------------------------+
+                |                  Browser                      |
+                |    server-rendered HTML  +  htmx fragments    |
+                +-----------------------------+-----------------+
+                                              | HTTPS (reverse proxy)
+                                              v
+   +-------------------------------------------------------------+
+   |                       overseer (binary)                     |
+   |                                                             |
+   |  +--------------------+   +-------------------------------+ |
+   |  | web layer          |   | inventory module library      | |
+   |  |  - mw::HTTPServer  |-->|  liboverseer_inventory        | |
+   |  |  - Inja templates  |   |  (Storage, Stuff, search,     | |
+   |  |  - session/cookies |   |   move history, attachments)  | |
+   |  |  - OIDC handler    |   +---------------+---------------+ |
+   |  +---------+----------+                   |                 |
+   |            |                              v                 |
+   |            |               +------------------------------+ |
+   |            +-------------->|  data access layer           | |
+   |                            |  - mw::SQLite wrapper        | |
+   |                            |  - migrations                | |
+   |                            |  - attachment store          | |
+   |                            +---------------+--------------+ |
+   |                                            |                |
+   +--------------------------------------------+----------------+
+                                                |
+                                                v
+                              /var/lib/overseer/overseer.db
+                              (single SQLite file; all data)
+```
+
+The shape to remember:
+
+- One process, one binary (`overseer`).
+- Three logical layers in-process: web, business logic, data.
+- Business logic is a separate static library
+  (`liboverseer_inventory`) so it can be linked from a future agent
+  surface.
+
+### 2.1 Why server-rendered HTML + htmx
+
+The PRD mandates Inja + htmx with no frontend build step. The reason
+this is a good choice for a tiny self-hosted family app:
+
+- **No bundler, no node_modules, no transpile step.** Editing a
+  template only requires restarting the C++ process (or hot-reload
+  in dev mode — see §11.4).
+- **htmx swaps HTML fragments.** The server keeps the source of
+  truth for every UI state. There is no client-side model to keep
+  in sync. The whole app behaves like classic server-rendered pages
+  with sprinkles of partial updates.
+- **Read [htmx’s essays](https://htmx.org/essays/)** —
+  particularly *Hypermedia-Driven Applications* — to understand the
+  mental model before writing endpoints.
+
+### 2.2 Why C++23 + libmw
+
+`prd.md` mandates C++23 and that we use **libmw** where applicable.
+libmw lives at `~/programs/libmw/includes/mw` and gives us:
+
+- `mw::HTTPServer` — a thin wrapper around `cpp-httplib`
+  ([http_server.hpp](../../libmw/includes/mw/http_server.hpp)).
+- `mw::SQLite` and `mw::SQLiteStatement` — a thread-safe RAII
+  wrapper around `sqlite3` with templated `bind()` / `eval()` (see
+  [database.hpp](../../libmw/includes/mw/database.hpp)).
+- `mw::AuthOpenIDConnect` — a discovery-based OIDC client
+  ([auth.hpp](../../libmw/includes/mw/auth.hpp)).
+- `mw::E<T>` and the `ASSIGN_OR_RETURN` macro for error propagation
+  ([error.hpp](../../libmw/includes/mw/error.hpp),
+  [utils.hpp](../../libmw/includes/mw/utils.hpp)).
+- `mw::SHA256Hasher` etc. for content hashing
+  ([crypto.hpp](../../libmw/includes/mw/crypto.hpp)).
+
+Use these first. Do not write a parallel SQLite or HTTP wrapper.
+
+---
+
+## 3. Repository layout
+
+The repo is laid out so that the inventory logic is physically
+separate from the web layer:
+
+```
+home-info/
+├── prd.md
+├── designs/
+│   └── design-0-architecture.md         (this file)
+├── CMakeLists.txt
+├── overseer.example.yaml                 (sample config for /etc/overseer.yaml)
+├── src/
+│   ├── overseer/                         (the binary)
+│   │   ├── main.cpp
+│   │   ├── config.hpp / config.cpp       (YAML loader)
+│   │   ├── server.hpp / server.cpp       (HTTP wiring; subclasses mw::HTTPServer)
+│   │   ├── session.hpp / session.cpp     (cookie/session store)
+│   │   ├── auth_handler.hpp/.cpp         (OIDC login/callback/logout)
+│   │   ├── attachment_handler.hpp/.cpp   (GET /attachment/{id})
+│   │   ├── render.hpp / render.cpp       (Inja env + helpers)
+│   │   ├── module.hpp                    (Module interface; see §13)
+│   │   └── modules/
+│   │       └── inventory/                (inventory HTTP routes only)
+│   │           ├── routes.hpp/.cpp
+│   │           ├── views.hpp/.cpp        (Inja-bound view models)
+│   │           └── templates/            (*.html.inja)
+│   ├── inventory/                        (library — pure logic)
+│   │   ├── include/inventory/
+│   │   │   ├── storage.hpp
+│   │   │   ├── stuff.hpp
+│   │   │   ├── search.hpp
+│   │   │   ├── attachment.hpp
+│   │   │   └── repo.hpp                  (entry-point façade)
+│   │   └── src/
+│   │       ├── storage.cpp
+│   │       ├── stuff.cpp
+│   │       ├── search.cpp
+│   │       └── attachment.cpp
+│   ├── db/
+│   │   ├── migrations.hpp/.cpp           (registry of migrateDBXToY)
+│   │   ├── migration_001_init.cpp        (schema v1)
+│   │   └── backup.hpp/.cpp               (pre-migration backup)
+│   └── static/
+│       ├── css/
+│       │   └── overseer.css              (plain CSS, no Bootstrap)
+│       └── js/
+│           └── htmx.min.js               (vendored — see §11.3)
+└── tests/
+    ├── inventory/                        (library unit tests)
+    ├── db/                               (migration round-trip tests)
+    └── e2e/                              (spin-up server, drive with HTTP)
+```
+
+**Naming follows the user style guide:** snake_case for file names,
+CapCase for types, snake_case for variables, camelCase for
+multi-word function names, lower-case for single-word functions,
+UPPER_CASE for global constants (including enum cases). 4-space
+indent, opening brace on its own line, no space before `(`.
+
+---
+
+## 4. Build system
+
+### 4.1 CMake
+
+We use **CMake ≥3.24** (matching the toolchain `nsblog` uses) and
+**C++23**. Most third-party libraries are pulled in with
+[FetchContent](https://cmake.org/cmake/help/latest/module/FetchContent.html)
+so the project builds without distro-specific packages — this
+matches the convention already used in
+`~/programs/nsblog/CMakeLists.txt`. For an introduction to modern
+CMake see [Modern CMake](https://cliutils.gitlab.io/modern-cmake/).
+
+Targets:
+
+| Target | Type | Description |
+|---|---|---|
+| `overseer_inventory` | static lib | The pure business-logic library |
+| `overseer_db` | static lib | Migrations + low-level DB helpers |
+| `overseer` | executable | The web binary; links the two libs above |
+| `overseer_test` | executable | GoogleTest-based test runner |
+
+### 4.2 External dependencies
+
+Pulled in with `FetchContent`:
+
+| Dependency | Purpose | Source |
+|---|---|---|
+| libmw | HTTP server, SQLite wrapper, OIDC, crypto | `https://github.com/MetroWind/libmw.git` |
+| nlohmann/json | Required by Inja | `v3.11.3` |
+| Inja | HTML templating | `pantor/inja` (main) |
+| spdlog | Logging (with `SPDLOG_USE_STD_FORMAT=ON`) | `v1.12.0` |
+| yaml-cpp | Config parsing | `jbeder/yaml-cpp` (`0.8.0`) |
+| googletest | Unit tests / mocks | `v1.14.0` |
+
+Pulled in via system packages (`find_package` / `pkg_check_modules`):
+
+| Dependency | Purpose | Find call |
+|---|---|---|
+| libcurl | Transitively required by libmw | `find_package(CURL REQUIRED)` |
+| sqlite3 | Storage engine | `find_package(SQLite3 REQUIRED)` |
+| Magick++ (ImageMagick 7) | Image resize + AVIF encode | `pkg_check_modules(Magick REQUIRED Magick++)` |
+
+Before `FetchContent_MakeAvailable(libmw …)`, set the libmw build
+flags we need (same names nsblog uses):
+
+```cmake
+set(LIBMW_BUILD_URL ON)
+set(LIBMW_BUILD_SQLITE ON)
+set(LIBMW_BUILD_HTTP_SERVER ON)
+set(LIBMW_BUILD_CRYPTO ON)
+set(SPDLOG_USE_STD_FORMAT ON)
+```
+
+Link targets exposed by libmw: `mw::mw`, `mw::sqlite`,
+`mw::http-server`, `mw::crypto` (plus `mw::url` if we ever need it).
+
+Inja’s headers are accessed via `${inja_SOURCE_DIR}/single_include/inja`
+and nlohmann/json via `${json_SOURCE_DIR}/single_include` — both
+matching the include layout nsblog uses.
+
+There is **no JS/Node toolchain**. `htmx.min.js` is vendored as a
+file under `src/static/js/` (see §11.3).
+
+### 4.3 Compile flags
+
+- `-std=c++23 -Wall -Wextra -Wpedantic -Werror`
+- `-fno-exceptions` is **not** used; we follow libmw’s style and use
+  `std::expected` for recoverable errors but allow exceptions from
+  third-party libs (Magick++, yaml-cpp) to bubble to the request
+  boundary, where they are caught and converted to a 500.
+
+---
+
+## 5. Configuration
+
+### 5.1 File location and format
+
+The PRD fixes the path: `/etc/overseer.yaml`. The path is **not**
+overridable in production; for tests and dev we pass a path via
+`--config /tmp/...`. The file is YAML 1.2 parsed by yaml-cpp.
+
+Schema:
+
+```yaml
+# /etc/overseer.yaml
+bind_address: "127.0.0.1"      # bind IP or unix:/path/to/socket
+port: 8080                     # ignored if bind_address starts with unix:
+log_level: "info"              # trace|debug|info|warn|error|critical
+db_path: "/var/lib/overseer/overseer.db"   # optional override; default per PRD
+
+oidc:
+  issuer_url: "https://keycloak.example.com/realms/family"
+  client_id: "overseer"
+  client_secret: "..."          # secret; restrict file perms to 0600
+  redirect_uri: "https://overseer.example.com/oidc/callback"
+
+session:
+  cookie_name: "overseer_sid"   # optional
+  max_age_minutes: 480          # optional, default 8h
+```
+
+### 5.2 Loading
+
+`config.hpp` declares:
+
+```c++
+struct OIDCConfig
+{
+    std::string issuer_url;
+    std::string client_id;
+    std::string client_secret;
+    std::string redirect_uri;
+};
+
+struct SessionConfig
+{
+    std::string cookie_name = "overseer_sid";
+    std::chrono::minutes max_age{480};
+};
+
+struct Config
+{
+    std::string bind_address;
+    int port = 8080;
+    std::string log_level = "info";
+    std::string db_path = "/var/lib/overseer/overseer.db";
+    OIDCConfig oidc;
+    SessionConfig session;
+};
+
+mw::E<Config> loadConfig(const std::string& path);
+```
+
+`loadConfig` MUST:
+
+1. `stat()` the file; return an error if it cannot be read.
+2. Reject the file if its mode is world-readable when
+   `oidc.client_secret` is present (log a warning at minimum;
+   refuse to start if mode `& 0o007` is non-zero).
+3. Parse with yaml-cpp. Wrap exceptions as `RuntimeError`.
+4. Validate: `bind_address` non-empty; `port` in `[1, 65535]` when
+   not a unix socket; OIDC `issuer_url`, `client_id`,
+   `client_secret`, `redirect_uri` all non-empty.
+5. Default optional fields explicitly (do not rely on
+   default-constructed values silently).
+
+### 5.3 Why YAML
+
+YAML is human-friendly for a single ops file, and yaml-cpp is a
+mature C++ option. The cost (whitespace sensitivity) is acceptable
+because the file is short and edited by one operator.
+
+---
+
+## 6. Database
+
+All persistent state goes into **one SQLite file** at
+`/var/lib/overseer/overseer.db`. This includes photos. SQLite was
+chosen because:
+
+- It is one file. Backups are `cp`. Atomicity is the engine’s
+  problem.
+- It supports the scale (≤2 concurrent users, thousands of rows,
+  hundreds of small photos).
+- It has FTS5 built in (for §10).
+- `mw::SQLite` already wraps it ergonomically and thread-safely
+  using SQLite’s default *serialized* threading mode
+  ([docs](https://www.sqlite.org/threadsafe.html)).
+
+### 6.1 Connection lifecycle
+
+A single `mw::SQLite` instance is owned by `Server`. The same
+instance is shared across HTTP threads — see
+[database.hpp](../../libmw/includes/mw/database.hpp) for why this is
+safe.
+
+On startup the connection runs the following pragmas, in order:
+
+```sql
+PRAGMA journal_mode=WAL;           -- crash safety, better concurrency
+PRAGMA synchronous=NORMAL;         -- WAL-safe and fast enough
+PRAGMA foreign_keys=ON;            -- enforce FKs; SQLite default is OFF
+PRAGMA busy_timeout=5000;          -- avoid spurious SQLITE_BUSY for our 2-user load
+PRAGMA temp_store=MEMORY;
+```
+
+These are documented in the [SQLite pragma reference](https://www.sqlite.org/pragma.html).
+`foreign_keys=ON` is critical — SQLite ships with FK enforcement
+off, which silently breaks our `ON DELETE` clauses if forgotten.
+
+### 6.2 Schema (version 1)
+
+This is the full DDL the **initial migration** must install. Every
+column has a stated reason.
+
+```sql
+-- ----- attachments (deduplicated by sha256) ------------------------------
+CREATE TABLE attachment
+(
+    id      INTEGER PRIMARY KEY AUTOINCREMENT,
+    mime    TEXT    NOT NULL,                   -- always "image/avif" in v1
+    sha256  TEXT    NOT NULL UNIQUE,            -- lowercase hex of post-processed bytes
+    bytes   BLOB    NOT NULL                    -- the AVIF body itself
+);
+
+-- ----- storages (tree via adjacency list) --------------------------------
+CREATE TABLE storage
+(
+    id            INTEGER PRIMARY KEY AUTOINCREMENT,
+    parent_id     INTEGER NULL REFERENCES storage(id) ON DELETE RESTRICT,
+    name          TEXT    NOT NULL,
+    description   TEXT    NULL,
+    attachment_id INTEGER NULL REFERENCES attachment(id) ON DELETE SET NULL,
+    UNIQUE(parent_id, name)
+);
+-- NB: SQLite treats NULLs as distinct in UNIQUE, so two roots with
+-- identical names would slip past the constraint. We guard with an
+-- additional partial index so root siblings are also unique:
+CREATE UNIQUE INDEX storage_root_name_unique
+    ON storage(name) WHERE parent_id IS NULL;
+
+CREATE INDEX storage_parent_idx ON storage(parent_id);
+
+-- ----- stuffs ------------------------------------------------------------
+CREATE TABLE stuff
+(
+    id            INTEGER PRIMARY KEY AUTOINCREMENT,
+    name          TEXT    NOT NULL,
+    description   TEXT    NULL,
+    attachment_id INTEGER NULL REFERENCES attachment(id) ON DELETE SET NULL,
+    storage_id    INTEGER NOT NULL REFERENCES storage(id) ON DELETE RESTRICT
+);
+CREATE INDEX stuff_storage_idx ON stuff(storage_id);
+
+-- ----- move history (rolling, max 10 per stuff) --------------------------
+CREATE TABLE stuff_move
+(
+    id         INTEGER PRIMARY KEY AUTOINCREMENT,
+    stuff_id   INTEGER NOT NULL REFERENCES stuff(id) ON DELETE CASCADE,
+    storage_id INTEGER NOT NULL REFERENCES storage(id) ON DELETE RESTRICT,
+    moved_at   INTEGER NOT NULL    -- unix epoch seconds, written by app
+);
+CREATE INDEX stuff_move_stuff_idx ON stuff_move(stuff_id, moved_at DESC);
+
+-- Trim the oldest entries so no stuff keeps more than 10 rows.
+CREATE TRIGGER stuff_move_trim
+AFTER INSERT ON stuff_move
+BEGIN
+    DELETE FROM stuff_move
+    WHERE stuff_id = NEW.stuff_id
+      AND id IN (
+        SELECT id FROM stuff_move
+         WHERE stuff_id = NEW.stuff_id
+         ORDER BY moved_at DESC, id DESC
+         LIMIT -1 OFFSET 10
+      );
+END;
+
+-- ----- FTS5 virtual tables (full text search) ----------------------------
+CREATE VIRTUAL TABLE storage_fts USING fts5(
+    name, description,
+    content='storage', content_rowid='id',
+    tokenize='unicode61 remove_diacritics 2'
+);
+CREATE VIRTUAL TABLE stuff_fts USING fts5(
+    name, description,
+    content='stuff', content_rowid='id',
+    tokenize='unicode61 remove_diacritics 2'
+);
+
+-- Sync triggers (one set per content table). The "external content"
+-- pattern is documented at https://www.sqlite.org/fts5.html#external_content_tables .
+CREATE TRIGGER storage_ai AFTER INSERT ON storage BEGIN
+    INSERT INTO storage_fts(rowid, name, description)
+    VALUES (NEW.id, NEW.name, NEW.description);
+END;
+CREATE TRIGGER storage_ad AFTER DELETE ON storage BEGIN
+    INSERT INTO storage_fts(storage_fts, rowid, name, description)
+    VALUES ('delete', OLD.id, OLD.name, OLD.description);
+END;
+CREATE TRIGGER storage_au AFTER UPDATE ON storage BEGIN
+    INSERT INTO storage_fts(storage_fts, rowid, name, description)
+    VALUES ('delete', OLD.id, OLD.name, OLD.description);
+    INSERT INTO storage_fts(rowid, name, description)
+    VALUES (NEW.id, NEW.name, NEW.description);
+END;
+-- (mirror triggers for stuff_ai / stuff_ad / stuff_au)
+```
+
+#### 6.2.1 Why adjacency list, not closure table
+
+The PRD calls it out explicitly: scale is ≤2 concurrent users.
+Recursive CTEs (`WITH RECURSIVE`,
+[docs](https://www.sqlite.org/lang_with.html)) handle path / subtree
+queries trivially at this scale, and adjacency lists are far simpler
+to mutate. Caching paths in memory (a `unordered_map<int64_t,
+std::string>`) is acceptable when needed for breadcrumbs.
+
+#### 6.2.2 Why `ON DELETE RESTRICT` for `storage.parent_id` and `stuff.storage_id`
+
+We never want a delete to silently destroy children. If a user tries
+to delete a non-empty storage, the SQL FK will block it; the web
+layer must catch this and prompt "this storage still contains
+N items, delete them first or move them" (see §13.4).
+
+#### 6.2.3 Why dedupe attachments by `sha256`
+
+A family will photograph the same outlet box from the same angle
+many times. Hashing the *post-processed* AVIF bytes means re-uploads
+collapse into a single row, and re-attaching to a different stuff
+costs only a foreign-key write.
+
+### 6.3 Versioning and migrations
+
+The schema version lives in SQLite’s built-in `PRAGMA user_version`
+([docs](https://www.sqlite.org/pragma.html#pragma_user_version)). It
+defaults to `0` on a freshly created file.
+
+Each migration is a free function with this exact shape:
+
+```c++
+namespace overseer::db
+{
+    mw::E<void> migrateDB0To1(mw::SQLite& db);   // create v1 schema from empty
+    mw::E<void> migrateDB1To2(mw::SQLite& db);   // future
+    // ...
+}
+```
+
+`migrations.cpp` keeps a registry (a `std::vector` of
+`{from, to, fn}` triples). The orchestrator is `migrateIfNeeded()`,
+which runs at startup:
+
+```
+function migrateIfNeeded(db):
+    current = db.PRAGMA user_version
+    target  = HIGHEST_KNOWN_VERSION    // compile-time constant
+    if current == target: return ok
+    if current >  target: return error "database is newer than binary"
+    backupDatabaseFile()               // see §6.4
+    for each migration ordered by `from`:
+        if migration.from < current: skip
+        BEGIN TRANSACTION
+            migration.fn(db)
+            PRAGMA user_version = migration.to
+        COMMIT
+    return ok
+```
+
+Hard requirements (from the PRD):
+
+- Each migration runs in **one transaction**. Use SQLite’s
+  `BEGIN IMMEDIATE; ... COMMIT;`. If the migration throws or
+  returns an error, `ROLLBACK`.
+- The server **refuses to start** when `user_version > target`.
+- A backup copy of the file is made **before any migration runs**
+  (not before each one — one backup per startup is sufficient and
+  cheaper). Skipped if `current == target`.
+
+### 6.4 Backups
+
+`backup.cpp` exposes:
+
+```c++
+mw::E<std::filesystem::path>
+backupDatabaseFile(mw::SQLite& db, const std::string& db_path);
+```
+
+It performs:
+
+1. Resolve `db_path` to an absolute path.
+2. Compute timestamp `YYYYmmdd-HHMMSS` from `std::chrono::system_clock`.
+3. Construct destination `db_path + ".bak-" + timestamp`.
+4. Execute `VACUUM INTO '<dest>';` on the live connection. This is
+   SQLite's SQL-level online-backup form and is safe with the WAL
+   active
+   ([docs](https://www.sqlite.org/lang_vacuum.html#vacuuminto)). It
+   produces a clean single-file copy without the WAL/SHM sidecars.
+   We deliberately do *not* `cp` the file because that can capture
+   an inconsistent WAL state. The lower-level
+   `sqlite3_backup_init / _step / _finish` API would produce an
+   equivalent snapshot, but `VACUUM INTO` keeps the call inside the
+   `mw::SQLite` facade and is sufficient for our pre-migration use
+   case.
+5. Return the path of the backup file. Log it at `info` level.
+
+These pre-migration snapshots are the **only** backups Overseer
+itself produces. Routine / periodic backups of
+`/var/lib/overseer/overseer.db` are an operator concern (e.g.
+`restic`, `borg`, host-level snapshots) — backup policy varies per
+deployment and doesn't belong inside the binary.
+
+### 6.5 Initial migration (v0 → v1)
+
+`migration_001_init.cpp` is the only migration that exists for v1.
+It executes the entire DDL block of §6.2, as a single string passed
+to `mw::SQLite::execute()` (which calls `sqlite3_exec` and supports
+multi-statement scripts). The version is bumped to `1` afterwards.
+
+---
+
+## 7. Authentication
+
+### 7.1 Flow
+
+Authorization Code (no PKCE) against an external Keycloak. See
+[OIDC Core §3.1](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth).
+
+Overseer is a **confidential** server-side client: the
+`client_secret` lives only on the server, and the browser never
+sees auth codes outside the TLS-terminated reverse proxy. Code
+interception by a third party would already require a TLS break
+*and* possession of the secret, so PKCE buys little extra in this
+deployment. Login CSRF is still defended against by an opaque
+`state` parameter (see §7.1.1).
+
+Endpoints we add:
+
+| Path | Method | Purpose |
+|---|---|---|
+| `/oidc/login` | GET | Generate `state`, remember it, 302 to Keycloak `authorization_endpoint`. |
+| `/oidc/callback` | GET | Verify `state`, exchange `code` for tokens, fetch userinfo, create session, set session cookie, 302 to original `return_to`. |
+| `/oidc/logout` | POST | Drop session, clear cookie, best-effort hit the IdP's `end_session_endpoint` (see §7.1.2). |
+
+`mw::AuthOpenIDConnect::create()` (see
+[auth.hpp](../../libmw/includes/mw/auth.hpp)) handles discovery
+(`{issuer}/.well-known/openid-configuration`) and the code-to-token
+exchange. Use it as-is.
+
+#### 7.1.1 State generation
+
+For each login:
+
+1. Generate a 16-byte random buffer (`/dev/urandom` or
+   `RAND_bytes`). Encode as base64url-without-padding → that is the
+   `state`. It binds the callback to this exact login attempt and
+   prevents login CSRF.
+2. libmw's `base64Encode` returns standard base64, so we replace
+   `+`→`-`, `/`→`_` and strip `=` padding. A short helper
+   `base64UrlNoPad()` in `auth_handler.cpp` is enough.
+3. Persist `{state -> {return_to, created_at}}` for 5 minutes. The
+   store is in-memory (`std::unordered_map` guarded by
+   `std::mutex`) — we do not need persistence across restarts for a
+   5-minute artifact.
+
+#### 7.1.2 Logout
+
+`POST /oidc/logout`:
+
+1. Look up the session and capture its `id_token` (to send as
+   `id_token_hint`).
+2. Delete the in-memory session and clear the session cookie.
+3. If the discovery document advertised an `end_session_endpoint`,
+   issue an asynchronous `GET` to it with `id_token_hint` and
+   `post_logout_redirect_uri` query parameters; log but do not
+   surface failures (the local session is already gone).
+4. Respond `302` to `/`.
+
+Do **not** hard-code the path: `end_session_endpoint` is optional
+in the OIDC discovery document
+([Keycloak OIDC discovery docs](https://www.keycloak.org/securing-apps/oidc-layers))
+and may be absent on non-Keycloak IdPs. Read it from
+`{issuer_url}/.well-known/openid-configuration`, which
+`mw::AuthOpenIDConnect::create()` already fetches at startup and
+exposes via its `endpoint_end_session` member (see
+[auth.hpp](../../libmw/includes/mw/auth.hpp)). When the discovery
+document does not contain the field, skip step 3 silently —
+local logout is sufficient.
+
+### 7.2 Sessions
+
+After a successful callback, we mint a session:
+
+- 32 bytes of randomness, base64url-no-pad → `session_id`. This is
+  what the cookie carries; the browser never sees tokens.
+- Tokens and the userinfo struct are stored in an in-memory
+  `std::unordered_map<std::string, Session>` guarded by a mutex.
+  Sessions live in memory because the family-scale traffic is
+  trivially small, and forcing a re-login after a restart is
+  acceptable.
+- Cookie attributes: `HttpOnly; Secure; SameSite=Lax; Path=/;
+  Max-Age=<configured>`. `Secure` is set unconditionally — the
+  reverse proxy must terminate TLS in production.
+- Idle expiry: tied to the access token’s `expiration`. When the
+  access token is about to expire and `refresh_token` exists, call
+  `mw::AuthOpenIDConnect::refreshTokens()` lazily on the next
+  request.
+
+### 7.3 Authorization
+
+The PRD: every authenticated user can read and write everything.
+There are no per-row ACLs. Implementation:
+
+- `Server::requireAuth(req, res) -> std::optional<UserInfo>`: a
+  helper that pulls the cookie, looks up the session, and on
+  failure responds with `302 Location: /oidc/login?return_to=…`
+  (for HTML routes) or `401` (for htmx fragments that don’t want a
+  redirect). Inventory route handlers call this helper first thing.
+- `who` for audit purposes: when we one day add an audit log, the
+  `UserInfo.name` and `UserInfo.id` are what we’ll record. v1 does
+  not need an audit log.
+
+### 7.4 CSRF
+
+htmx requests from our own pages travel via the session cookie. To
+prevent cross-site form posts:
+
+- Every mutating endpoint (`POST`, `PUT`, `DELETE`) requires either:
+  - The `HX-Request: true` header (set automatically by htmx for
+    its own requests) **plus** a same-origin check on the `Origin`
+    or `Referer` header, OR
+  - A double-submit CSRF token (`csrf_token` form field that must
+    match a `csrf_token` cookie). Templates inject the token.
+
+The double-submit token is simpler to reason about and is what we
+ship. The token is 16 random bytes per session, set as a
+non-HttpOnly cookie so htmx can read it via
+`hx-headers='{"X-CSRF-Token": "..."}'`. Reject mismatches with 403.
+
+---
+
+## 8. HTTP server
+
+### 8.1 Why `mw::HTTPServer`
+
+`mw::HTTPServer` wraps `cpp-httplib`. It already gives us start /
+stop / wait, ListenAddress union of TCP + Unix socket, and the
+`ASSIGN_OR_RESPOND_ERROR` macro that converts `mw::E<T>` failures
+into HTTP responses with the right status. See
+[http_server.hpp](../../libmw/includes/mw/http_server.hpp).
+
+### 8.2 Subclassing
+
+We define `class Server : public mw::HTTPServer`. `setup()` is the
+single registration point:
+
+```c++
+void Server::setup()
+{
+    server.set_logger(...);                // pipe access logs to spdlog
+    server.set_default_headers(...);       // X-Content-Type-Options etc.
+    server.set_exception_handler(...);     // last-resort 500
+
+    auth_handler.registerRoutes(server);
+    attachment_handler.registerRoutes(server);
+    for(auto& mod : modules) mod->registerRoutes(server);
+
+    server.Get("/static/.*", [](auto& req, auto& res){ ... });   // static files
+    server.Get("/healthz", [](auto&, auto& res){ res.status = 204; });
+}
+```
+
+### 8.3 Concurrency
+
+`cpp-httplib` is thread-per-request by default. SQLite is serialized
+so simultaneous handlers can call into the same `mw::SQLite`
+instance. The in-memory session and CSRF maps are guarded by
+`std::mutex`.
+
+### 8.4 Static files
+
+`/static/` serves files from `src/static/`. In production we either:
+
+- Bundle them next to the binary in `/usr/share/overseer/static/`
+  and locate them via a compile-time `OVERSEER_STATIC_DIR`, or
+- Let the reverse proxy serve them.
+
+In dev mode `--static-dir <path>` overrides for hot edits.
+
+### 8.5 Errors
+
+- Use `mw::E<T>` everywhere a function can fail.
+- At the route boundary, use `ASSIGN_OR_RESPOND_ERROR` (defined in
+  `mw/http_server.hpp`) to translate errors into HTTP responses.
+- Unhandled C++ exceptions are caught by the `exception_handler`
+  and converted to a 500 with a short body and a logged stack
+  trace.
+
+### 8.6 Logging
+
+`spdlog` is configured in `main()`:
+
+- Sinks: stderr (color) in foreground mode, `journald` when running
+  under systemd (detect via `JOURNAL_STREAM` env var; otherwise
+  still stderr — systemd captures it).
+- Pattern: `[%l] [%t] %v`. We deliberately omit the timestamp from
+  the spdlog pattern because both downstream sinks add their own:
+  journald stamps every entry, and the stderr color sink under
+  systemd flows through the journal as well. Locally during dev,
+  the prefix-less pattern keeps the line short and readable.
+- Level set from `config.log_level`.
+- Access log line per request: method, path, status, ms,
+  bytes_out, user_id (when known).
+
+---
+
+## 9. Inventory module
+
+This is the **only feature module** in v1. The split between the
+library (`liboverseer_inventory`) and the HTTP routes
+(`src/overseer/modules/inventory/`) is the central design call: it
+satisfies the PRD’s requirement that future agent surfaces can
+reuse the logic.
+
+### 9.1 Domain types
+
+```c++
+namespace overseer::inventory
+{
+    struct Storage
+    {
+        int64_t id = 0;
+        std::optional<int64_t> parent_id;     // nullopt = root
+        std::string name;
+        std::optional<std::string> description;
+        std::optional<int64_t> attachment_id;
+    };
+
+    struct Stuff
+    {
+        int64_t id = 0;
+        std::string name;
+        std::optional<std::string> description;
+        std::optional<int64_t> attachment_id;
+        int64_t storage_id = 0;
+    };
+
+    struct StuffMove
+    {
+        int64_t storage_id;
+        mw::Time moved_at;
+    };
+}
+```
+
+### 9.2 Library API (`inventory/repo.hpp`)
+
+`InventoryRepo` is the façade everyone calls. It is constructed
+with a `mw::SQLite&`. Methods return `mw::E<T>`.
+
+```c++
+class InventoryRepo
+{
+public:
+    explicit InventoryRepo(mw::SQLite& db);
+
+    // ---- storages ----------------------------------------------------
+    mw::E<int64_t>           createStorage(const Storage& s);
+    mw::E<Storage>           getStorage(int64_t id);
+    mw::E<std::vector<Storage>> listChildren(std::optional<int64_t> parent_id);
+    mw::E<std::vector<Storage>> pathTo(int64_t id);            // root → id
+    mw::E<void>              updateStorage(const Storage& s);
+    mw::E<void>              deleteStorage(int64_t id);        // fails if non-empty
+
+    // ---- stuffs ------------------------------------------------------
+    mw::E<int64_t>           createStuff(const Stuff& s);
+    mw::E<Stuff>             getStuff(int64_t id);
+    mw::E<std::vector<Stuff>> listInStorage(int64_t storage_id);
+    mw::E<void>              updateStuff(const Stuff& s);
+    mw::E<void>              moveStuff(int64_t stuff_id, int64_t new_storage_id);
+    mw::E<void>              deleteStuff(int64_t id);
+    mw::E<std::vector<StuffMove>> recentMoves(int64_t stuff_id);  // ≤10
+
+    // ---- search ------------------------------------------------------
+    mw::E<std::vector<Storage>> searchStorages(std::string_view q);
+    mw::E<std::vector<Stuff>>   searchStuffs(std::string_view q);
+
+    // ---- attachments -------------------------------------------------
+    // Accepts ANY input image, processes to AVIF ≤1024px, dedupes, returns id.
+    mw::E<int64_t>           putAttachment(std::span<const std::byte> bytes,
+                                           std::string_view source_mime);
+    mw::E<Attachment>        getAttachment(int64_t id);   // mime + bytes
+};
+```
+
+The HTTP routes call **only** these methods; they never run SQL
+directly. This is the seam that lets us later add an MCP server or
+REST agent surface without going through the web layer.
+
+### 9.3 Important implementation notes
+
+#### 9.3.1 `moveStuff`
+
+```
+BEGIN IMMEDIATE
+    SELECT storage_id FROM stuff WHERE id=?           -- current
+    if current == new_storage_id: COMMIT and return ok (no-op)
+    INSERT INTO stuff_move(stuff_id, storage_id, moved_at)
+        VALUES (?, current, unixepoch())               -- record OLD location
+    UPDATE stuff SET storage_id=? WHERE id=?
+COMMIT
+```
+
+The trigger from §6.2 trims rows >10 automatically. We insert the
+*previous* storage (history is "where I came from"), matching the
+PRD’s "previous `storage_id` is appended".
+
+#### 9.3.2 `deleteStorage`
+
+A FK `ON DELETE RESTRICT` on both `storage.parent_id` and
+`stuff.storage_id` enforces non-empty. The library translates the
+SQLite constraint error into a typed `RuntimeError{"NOT_EMPTY"}` so
+the HTTP layer can show a useful message.
+
+#### 9.3.3 `pathTo`
+
+```sql
+WITH RECURSIVE path(id, parent_id, name, depth) AS (
+    SELECT id, parent_id, name, 0 FROM storage WHERE id = :id
+    UNION ALL
+    SELECT s.id, s.parent_id, s.name, p.depth + 1
+      FROM storage s JOIN path p ON s.id = p.parent_id
+)
+SELECT id, parent_id, name FROM path ORDER BY depth DESC;
+```
+
+Reversal yields a root-to-leaf list which we feed to breadcrumb
+rendering.
+
+---
+
+## 10. Search
+
+### 10.1 Building
+
+FTS5 indexes are populated by the triggers defined in §6.2. New
+rows in `storage` / `stuff` immediately become searchable; updates
+delete-and-reinsert in the FTS shadow table; deletes remove.
+
+### 10.2 Querying
+
+`searchStorages("kitchen drawer")` runs:
+
+```sql
+SELECT s.id, s.parent_id, s.name, s.description, s.attachment_id
+  FROM storage s
+  JOIN storage_fts f ON f.rowid = s.id
+ WHERE storage_fts MATCH :q
+ ORDER BY rank
+ LIMIT 50;
+```
+
+Sanitization: the FTS5 query mini-language is permissive but
+exposes operators (`AND`, `OR`, `NEAR`, `"phrase"`, `*`). For the UI
+we accept the user’s string verbatim **but** wrap it as a
+double-quoted phrase plus prefix wildcard to make typing simple:
+`"<user input with internal quotes escaped>" *`. Power users can
+opt into raw mode by prefixing `~` (UI affordance; not in v1).
+
+Read the [FTS5 docs](https://www.sqlite.org/fts5.html) for syntax
+and `rank` semantics before changing this.
+
+### 10.3 UI behavior
+
+A search box (top-right of every page) uses htmx:
+
+```html
+<input type="search" name="q"
+       hx-get="/inventory/search"
+       hx-trigger="input changed delay:200ms, search"
+       hx-target="#results"
+       hx-push-url="true"
+       placeholder="Search stuff and storages…">
+<div id="results"></div>
+```
+
+The server endpoint returns a fragment listing matches across both
+tables, grouped under "Storages" and "Stuffs". On `Enter` the
+browser submits a real `GET /inventory/search?q=…` so the URL is
+shareable.
+
+---
+
+## 11. Web UI
+
+### 11.1 Inja templates
+
+Inja templates are plain HTML with `{{ var }}`, `{% if %}`,
+`{% for %}` tags ([README](https://github.com/pantor/inja)). We
+keep a single `inja::Environment` in `Render`, with:
+
+- `set_trim_blocks(true)` and `set_lstrip_blocks(true)` to keep
+  whitespace tidy.
+- A custom callback `url_for("storage", id)` etc. so templates
+  don’t hardcode URLs.
+- A custom callback `escape_attr(s)` mapped to `mw::escapeHTML`.
+- The template root is `src/overseer/modules/inventory/templates/`
+  (per-module). v1 has no single `_base.html.inja`; the shared shell
+  is split into two partials (`_head.html.inja`, `_topbar.html.inja`)
+  that each page template `{% include %}`s directly. This keeps the
+  pages flat — inja is happy with partial includes and the per-page
+  `<head>` differs enough (page title, occasional inline data) that a
+  full base template was more friction than help. When the Doc module
+  lands it brings its own templates.
+
+### 11.2 Template inventory (v1)
+
+| Template | Renders |
+|---|---|
+| `_head.html.inja` | `<head>` partial: stylesheet, htmx, CSRF meta |
+| `_topbar.html.inja` | nav bar partial: brand link, search box, user/logout |
+| `_breadcrumbs.html.inja` | storage path breadcrumb partial |
+| `storage_list.html.inja` | root view: tree of storages |
+| `storage_show.html.inja` | one storage: photo, children, stuff in it |
+| `storage_form.html.inja` | create/edit form (also used as htmx fragment) |
+| `stuff_show.html.inja` | one stuff: photo, current location, recent moves |
+| `stuff_form.html.inja` | create/edit form |
+| `search_page.html.inja` | full search results page |
+| `search_results.html.inja` | htmx fragment of grouped matches |
+| `fragment_tree_node.html.inja` | one expandable tree row (lazy load) |
+| `fragment_stuff_name.html.inja` | inline-edit display fragment (the clickable name) |
+| `fragment_stuff_name_edit.html.inja` | inline-edit form fragment |
+| `fragment_move_status.html.inja` | "moved to X" htmx response fragment |
+| `error.html.inja` | styled error page (full HTML) |
+
+### 11.3 htmx use cases (concrete)
+
+| Interaction | Pattern |
+|---|---|
+| Expand a tree node | `hx-get="/inventory/storage/{id}/children"`, target a `<ul>` placeholder, `hx-swap="innerHTML"` |
+| Live search | `hx-get` with `delay:200ms`, target results pane |
+| Inline edit name | swap `<span>` for `<form>`, `hx-put="/inventory/stuff/{id}/name"`, on success `hx-swap` back to span |
+| Delete without reload | `hx-delete="…"`, `hx-confirm="Sure?"`, `hx-swap="outerHTML"` on `<tr>` |
+| Move a stuff | `<select>` with `hx-post="/inventory/stuff/{id}/move"`, swap status banner |
+
+`htmx.min.js` is vendored at `src/static/js/htmx.min.js` (a single
+file; no build step). The exact version is recorded in
+`src/static/js/HTMX_VERSION.txt` so we know what we’re running.
+
+### 11.4 CSS
+
+Plain CSS, no framework, no Sass.
+`src/static/css/overseer.css` contains:
+
+- A small reset (box-sizing, body margin).
+- CSS variables for color tokens; one light and one dark scheme
+  switched by `@media (prefers-color-scheme: dark)`.
+- Grid for forms; flex for the top bar; a `.tree` block for the
+  storage tree (`details`/`summary` works fine, no JS needed for
+  the static parts).
+
+### 11.5 Dev-mode template hot reload
+
+When the binary is started with `--dev`, `inja::Environment` is
+recreated on every request and templates are loaded from disk each
+time. In production the env is built once at startup.
+
+---
+
+## 12. Attachments
+
+### 12.1 Upload pipeline
+
+1. Browser submits `multipart/form-data` with field `photo` to
+   `POST /inventory/storage/{id}/photo` (or `/stuff/{id}/photo`).
+2. Handler validates `Content-Type` against an allow-list:
+   `image/jpeg`, `image/png`, `image/webp`, `image/heic`,
+   `image/avif`, `image/gif`. Reject otherwise (415).
+3. Cap raw body size at 20 MiB
+   (`server.set_payload_max_length(20 * 1024 * 1024)`).
+4. Hand bytes to `InventoryRepo::putAttachment()`.
+
+**Coder availability.** Whether a given input format actually
+decodes is determined by which delegates ImageMagick was compiled
+against. HEIC in particular requires libheif and is not present in
+every distribution build, so iPhone uploads on those hosts will
+fail at decode time. At startup, log a single `warn`-level line
+listing the coders `Magick::coderInfoList()` reports as readable
+— that makes it obvious from the journal which formats this build
+of Overseer can accept.
+
+### 12.2 `putAttachment` internals
+
+```
+Magick::Image img;
+img.read(Magick::Blob(bytes.data(), bytes.size()));   // throws on bad input
+size_t w = img.columns(), h = img.rows();
+size_t longest = std::max(w, h);
+if(longest > 1024)
+{
+    double scale = 1024.0 / longest;
+    img.resize(Magick::Geometry(
+        size_t(w * scale + 0.5), size_t(h * scale + 0.5)));
+}
+img.strip();                  // drop EXIF (privacy)
+img.magick("AVIF");
+img.quality(60);              // good visual quality at small size
+Magick::Blob out;
+img.write(&out);
+```
+
+Magick++ throws `Magick::Exception` subclasses; catch them at the
+boundary and translate to a 400 with a useful message
+([reference](https://imagemagick.org/Magick++/Exception.html)).
+
+Then:
+
+```
+sha = SHA256Hasher.hashToHexStr(out_bytes)            // libmw helper
+existing = SELECT id FROM attachment WHERE sha256=?
+if existing: return existing.id                       // dedupe hit
+INSERT INTO attachment(mime, sha256, bytes) VALUES ('image/avif', sha, out_bytes)
+return last_insert_rowid()
+```
+
+### 12.3 Serving
+
+`GET /attachment/{id}`:
+
+1. `SELECT mime, sha256, bytes FROM attachment WHERE id=?`. 404 if
+   missing.
+2. Compute `ETag: "<sha256>"`. If the request’s
+   `If-None-Match` equals this ETag, respond `304` with no body.
+3. Otherwise respond `200` with:
+   - `Content-Type: image/avif`
+   - `Cache-Control: private, max-age=31536000, immutable`
+   - `ETag: "<sha256>"`
+   - The bytes.
+
+The "immutable" caching is safe because attachments are
+content-addressed by `sha256`; they cannot change in place. A
+re-uploaded photo that hashes differently becomes a different `id`.
+
+### 12.4 Why we do not put attachments on disk
+
+The PRD mandates that all data — including attachments — live in
+the single SQLite file. SQLite BLOB handling is fine up to ~1 GiB
+per row; family photo thumbnails post-resize are ~50–150 KiB. A
+single-file backup is the primary win.
+
+---
+
+## 13. URL design
+
+URLs are stable, hackable, and module-prefixed so future modules
+don’t collide.
+
+### 13.1 Conventions
+
+- Module routes are mounted under `/<module>/...` (e.g.
+  `/inventory/...`, future `/docs/...`).
+- Cross-module global routes: `/`, `/static/...`, `/attachment/{id}`,
+  `/oidc/...`, `/healthz`.
+- Trailing slashes are **not** used. Redirect `/path/` → `/path`.
+- IDs are integers.
+- HTML routes return full pages on `GET`; htmx-targeted routes
+  return a fragment when the request carries `HX-Request: true`,
+  else the full page (graceful degradation).
+
+### 13.2 Inventory routes (v1)
+
+| Method | Path | Purpose |
+|---|---|---|
+| GET    | `/` | redirect to `/inventory` |
+| GET    | `/healthz` | liveness probe (204) |
+| GET    | `/inventory` | root storage tree |
+| GET    | `/inventory/search?q=` | search across both tables |
+| GET    | `/inventory/storage/new` | create form |
+| POST   | `/inventory/storage` | create |
+| GET    | `/inventory/storage/{id}` | show one storage |
+| GET    | `/inventory/storage/{id}/edit` | edit form |
+| PUT    | `/inventory/storage/{id}` | update |
+| DELETE | `/inventory/storage/{id}` | delete (must be empty) |
+| GET    | `/inventory/storage/{id}/children` | htmx fragment: child rows |
+| POST   | `/inventory/storage/{id}/photo` | upload photo |
+| GET    | `/inventory/stuff/new?storage_id=` | create form |
+| POST   | `/inventory/stuff` | create |
+| GET    | `/inventory/stuff/{id}` | show one stuff |
+| GET    | `/inventory/stuff/{id}/edit` | edit form |
+| PUT    | `/inventory/stuff/{id}` | update |
+| DELETE | `/inventory/stuff/{id}` | delete |
+| POST   | `/inventory/stuff/{id}/move` | record a move |
+| POST   | `/inventory/stuff/{id}/photo` | upload photo |
+| GET    | `/inventory/stuff/{id}/name` | htmx fragment: the name span (cancel target) |
+| GET    | `/inventory/stuff/{id}/name/edit` | htmx fragment: the inline-edit form |
+| PUT    | `/inventory/stuff/{id}/name` | inline rename; returns the span fragment on `HX-Request` |
+| GET    | `/attachment/{id}` | serve a stored attachment (image/avif) |
+| GET    | `/oidc/login` | start OIDC authorization-code flow |
+| GET    | `/oidc/callback` | OIDC callback, sets session cookies |
+| POST   | `/oidc/logout` | drop session + RP-initiated IdP logout |
+
+Why `PUT`/`DELETE`: htmx supports them natively
+([attrs](https://htmx.org/attributes/hx-put/)). Browsers don’t
+expose `PUT`/`DELETE` on a `<form>` directly — that doesn’t matter
+because we drive mutations from htmx, not bare forms.
+
+### 13.3 Module interface
+
+```c++
+namespace overseer
+{
+    class Server;   // forward decl
+
+    class Module
+    {
+    public:
+        virtual ~Module() = default;
+        virtual std::string name() const = 0;                   // "inventory"
+        // `app` gives the module access to the repo, render, auth,
+        // expectedOrigin, etc. cpp-httplib's `s` is where the routes
+        // are actually registered; `app` is the surrounding context
+        // the handlers need.
+        virtual void registerRoutes(httplib::Server& s,
+                                    ::overseer::Server& app) = 0;
+        virtual mw::E<void> migrate(mw::SQLite& db) { return {}; }
+    };
+}
+```
+
+`Server` owns a `std::vector<std::unique_ptr<Module>>`. v1 contains
+exactly one entry, `InventoryModule`. Adding the Doc module later
+means a new subclass and one line in `main()`.
+
+The two-arg form is deliberate: handlers are non-trivial enough that
+they need the repo/render/auth on every call, and threading those
+through closures or globals would be worse. Modules that don't need
+the wider context can simply ignore `app`.
+
+### 13.4 Standard error pages
+
+| Status | When | Body |
+|---|---|---|
+| 400 | malformed input, image processing failed | `error.html.inja` with message |
+| 403 | CSRF failure | static body |
+| 404 | unknown route or row | `error.html.inja` |
+| 409 | unique constraint (sibling name collision); non-empty delete | descriptive |
+| 415 | unsupported image type | descriptive |
+| 500 | uncaught | "Something went wrong"; details in logs |
+
+---
+
+## 14. Testing strategy
+
+### 14.1 Layers
+
+| Layer | Tool | What it tests |
+|---|---|---|
+| Library unit tests | GoogleTest | `InventoryRepo` against an in-memory `mw::SQLite::connectMemory()` DB |
+| Migration tests | GoogleTest | Build empty DB → run migrations → assert schema/version; round-trip across all known versions |
+| HTTP integration | GoogleTest + `httplib::Client` | Spin a `Server` on `127.0.0.1:0`, drive real requests, assert HTML structure (string contains is enough for v1) |
+| Manual smoke | Browser | Test htmx flows visually |
+
+### 14.2 Coverage targets
+
+- 100 % of `InventoryRepo` methods exercised in unit tests for
+  both success and failure paths.
+- All migrations tested for forward application.
+- All routes exercised at least once at the integration layer.
+
+### 14.3 Test data
+
+A helper `seedFamily(InventoryRepo&)` in `tests/support/` creates a
+3-level tree (Apartment > Room > Shelf), 20 stuffs, 5 attachments.
+
+### 14.4 Why no end-to-end browser tests in v1
+
+Family-scale; the cost of maintaining Playwright tests outweighs
+the benefit when there are no JS-heavy interactions. htmx fragments
+are exercisable via HTTP assertions.
+
+---
+
+## 15. Deployment
+
+### 15.1 systemd unit (operator-owned, not shipped in the repo)
+
+The operator (whoever is deploying) is expected to drop a unit file
+at `/etc/systemd/system/overseer.service`. We do **not** ship the
+unit in the repo — deployment layout is a packaging concern, not a
+source concern. A reasonable starting point looks like:
+
+```
+[Unit]
+Description=Overseer family information system
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/overseer --config /etc/overseer.yaml
+User=overseer
+Group=overseer
+ProtectSystem=strict
+ReadWritePaths=/var/lib/overseer
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectHome=true
+Restart=on-failure
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+### 15.2 Filesystem permissions
+
+- `/etc/overseer.yaml`: owner `root:overseer`, mode `0640`.
+- `/var/lib/overseer/`: owner `overseer:overseer`, mode `0750`.
+- `/var/lib/overseer/overseer.db`: owner `overseer:overseer`, mode
+  `0640`. WAL/SHM siblings inherit the same.
+
+### 15.3 Reverse proxy
+
+A reverse proxy (nginx / Caddy) terminates TLS and forwards to
+`127.0.0.1:8080` (or a Unix socket). The binary itself does no TLS.
+The proxy must set `X-Forwarded-Proto: https` so cookies are issued
+with `Secure` correctly — or simply trust the operator and always
+set `Secure` (we do this; the production deployment must always be
+behind TLS).
+
+### 15.4 Upgrade procedure
+
+1. `systemctl stop overseer`.
+2. Replace the binary.
+3. `systemctl start overseer`.
+4. Watch logs: on startup the new binary checks `user_version`, and
+   if it is below the binary’s target it backs up
+   `overseer.db` to `overseer.db.bak-<timestamp>` and runs
+   migrations. Refuses to start if the DB is newer than the binary
+   (see §6.3).
+
+---
+
+## 16. Future modules (just the seams)
+
+This section is informational; nothing here is built in v1.
+
+- **Doc module** — would add `document` and `revision` tables, a
+  `/docs/...` route prefix, and its own templates. It would
+  register as another `Module` subclass. Attachments table is
+  already module-agnostic, so PDFs and images attach the same way.
+- **Agent surface** — would link `liboverseer_inventory` directly
+  (no HTTP) and expose either MCP or a JSON REST. Because the
+  library uses `mw::E<T>` already, the surface only has to convert
+  results to its on-the-wire format.
+
+---
+
+## 17. Recommended implementation order
+
+1. CMake skeleton + `main()` that prints `hello`.
+2. Config loader + sample YAML; refuse to start on bad config.
+3. `mw::SQLite` connection + pragmas + empty file at `db_path`.
+4. Migrations registry + v0→v1 migration + backup on apply.
+5. `InventoryRepo::createStorage / getStorage / listChildren` plus
+   their tests (in-memory DB).
+6. Bare HTTP server (no auth) with `/healthz` and `/inventory`
+   listing root storages via Inja.
+7. Storage CRUD routes end-to-end.
+8. Stuff CRUD + move history.
+9. FTS5 triggers + search route.
+10. Attachment pipeline + photo upload.
+11. OIDC: login, callback, sessions, cookies, CSRF.
+12. Polish CSS; htmx tree expansion; htmx live search; inline edit.
+13. systemd unit; package; deploy behind a reverse proxy.
+
+Each step ends with passing tests for the slice it added.
diff --git a/overseer.example.yaml b/overseer.example.yaml
new file mode 100644
index 0000000..d35615b
--- /dev/null
+++ b/overseer.example.yaml
@@ -0,0 +1,18 @@
+# Sample /etc/overseer.yaml. Copy to /etc/overseer.yaml and adjust.
+# Mode SHOULD be 0640 root:overseer or stricter — the OIDC client_secret
+# is sensitive.
+
+bind_address: "127.0.0.1"           # IP or "unix:/path/to/socket"
+port: 8080                          # ignored when bind_address starts with "unix:"
+log_level: "info"                   # trace|debug|info|warn|error|critical
+db_path: "/var/lib/overseer/overseer.db"
+
+oidc:
+    issuer_url:    "https://keycloak.example.com/realms/family"
+    client_id:     "overseer"
+    client_secret: "REPLACE_ME"
+    redirect_uri:  "https://overseer.example.com/oidc/callback"
+
+session:
+    cookie_name: "overseer_sid"
+    max_age_minutes: 480
diff --git a/prd.md b/prd.md
new file mode 100644
index 0000000..17cd5ff
--- /dev/null
+++ b/prd.md
@@ -0,0 +1,127 @@
+# Overseer: family information system
+
+A modular family information system in C++23.
+
+## Overall requirements
+
+- Use libmw when applicable. See `~/programs/libmw/includes/mw` for
+  headers.
+- Web interface with `mw::HTTPServer`.
+- All data in a single SQLite database, including attachments like
+  photos.
+- Logging via `spdlog`.
+- Configuration file at `/etc/overseer.yaml`.
+- The SQLite database file lives at `/var/lib/overseer/overseer.db`.
+- Modular: it will contain multiple modules that do different
+  things:
+  * Inventory system: record where things are stored at home
+  * Doc system: store and view documents
+- Only the inventory system is planned for now.
+
+## Web architecture
+
+- Server-rendered HTML using the **Inja** template engine.
+- Interactive bits (tree expand/collapse, live search, inline edit,
+  delete-without-reload) use **htmx**. Endpoints return HTML
+  fragments that htmx swaps into the page. No JSON API, no frontend
+  build step.
+- Plain CSS. No Bootstrap.
+- Inventory logic lives in a separate library
+  (e.g. `liboverseer_inventory`) that the web server links against,
+  so a future agent-facing surface (MCP, REST) can reuse it without
+  going through the web layer. Agent access itself is out of scope
+  for now.
+
+## Configuration
+
+`/etc/overseer.yaml` specifies at minimum:
+
+- `bind_address` and `port` for the HTTP server.
+- OIDC settings: `issuer_url` (or discovery endpoint), `client_id`,
+  `client_secret`, `redirect_uri`.
+- Optional log level.
+
+## Authentication
+
+- External OpenID Connect service (a KeyCloak server) is used for
+  authentication.
+- Authorization code flow. Tokens stay server-side; the
+  browser only holds an opaque session cookie.
+- Multi-user from an auth perspective: every request is tied to an
+  authenticated OIDC user.
+- The app manages a single family's data. Storages and stuffs are
+  **not** owned per-user — any authenticated user can read and
+  modify any row. No per-row ACLs.
+
+## Database
+
+- Schema is versioned using the `user_version` pragma.
+- Whenever the schema version is increased, a function to migrate the
+  database from the previous version should be written. For example:
+  `migrateDB1To2()`. The server runs this automatically on startup.
+- Migration rules:
+  * Each migration runs inside a single transaction.
+  * The server refuses to start if the database's `user_version` is
+    **higher** than the version the binary knows about.
+  * The server takes a backup copy of the database file before
+    running any migration.
+
+## Attachments
+
+- Photos (and future attachments) are stored in a dedicated
+  `attachment` table: `id`, `mime`, `sha256`, `bytes`. Storages and
+  stuffs reference attachments by id, so the same photo is not
+  duplicated.
+- On upload, photos are processed with Magick++
+  (https://imagemagick.org/magick++/):
+  * Scaled down so the long side is at most 1024px (no upscaling).
+  * Encoded as AVIF.
+- Attachments are served via a dedicated route (e.g.
+  `/attachment/{id}`) with the correct `Content-Type` and an `ETag`
+  for caching. Do not inline image bytes into HTML.
+
+## Search
+
+- Full-text search over `name` and `description` for both storages
+  and stuffs, using SQLite FTS5.
+- The FTS virtual tables are created and kept in sync via triggers,
+  set up in the initial migration.
+
+## Inventory system
+
+### Storages
+
+- A storage is a physical place to store stuff at home (for example
+  "the book shelf").
+- Storages form a tree using an **adjacency list**
+  (`parent_id` → `storage.id`). No path caching in the database; the
+  server may compute paths on demand or cache them in memory. The app
+  is small-scale (≤2 concurrent users).
+- `(parent_id, name)` is unique — siblings cannot share a name.
+- Fields: `name` (required), `description` (optional),
+  `attachment_id` (optional, the photo).
+- Users can create, edit, and delete storages.
+
+### Stuff
+
+- A stuff is an item stored in exactly one storage at a time.
+- Fields: `name` (required), `description` (optional),
+  `attachment_id` (optional), `storage_id` (required — current
+  location).
+- Users can add, edit, and delete stuff.
+- **Move history**: each stuff keeps a rolling history of its last
+  10 storage locations in a separate `stuff_move` table
+  (`stuff_id`, `storage_id`, `moved_at`). When a stuff is moved to a
+  new storage, the previous `storage_id` is appended as a new row.
+  A trigger trims the oldest entries so that no stuff has more than
+  10 history rows.
+
+### Search UI
+
+- Users can search for storages and stuffs by name and description.
+
+## URL design
+
+- Each storage and stuff should have its own unique URL.
+- Keep in mind that the inventory system is just one of multiple
+  modules.
diff --git a/src/db/backup.cpp b/src/db/backup.cpp
new file mode 100644
index 0000000..fa3dd19
--- /dev/null
+++ b/src/db/backup.cpp
@@ -0,0 +1,71 @@
+#include "backup.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <format>
+#include <string>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+#include <spdlog/spdlog.h>
+
+namespace overseer::db
+{
+
+namespace
+{
+
+std::string nowStamp()
+{
+    auto now = std::chrono::system_clock::now();
+    return std::format("{:%Y%m%d-%H%M%S}",
+                       std::chrono::floor<std::chrono::seconds>(now));
+}
+
+// Single-quote escape for inline SQL string literals.
+std::string sqlQuote(const std::string& s)
+{
+    std::string out;
+    out.reserve(s.size() + 2);
+    out.push_back('\'');
+    for(char c : s)
+    {
+        if(c == '\'')
+        {
+            out.push_back('\'');
+            out.push_back('\'');
+        }
+        else
+        {
+            out.push_back(c);
+        }
+    }
+    out.push_back('\'');
+    return out;
+}
+
+} // namespace
+
+mw::E<std::filesystem::path>
+backupDatabaseFile(mw::SQLite& db, const std::string& db_path)
+{
+    std::filesystem::path src = db_path;
+    auto abs = std::filesystem::absolute(src);
+    auto dest = abs;
+    dest += ".bak-" + nowStamp();
+
+    // VACUUM INTO is the documented online-backup-equivalent SQL form
+    // and it is safe with WAL active. See
+    // https://www.sqlite.org/lang_vacuum.html#vacuuminto
+    std::string sql = "VACUUM INTO " + sqlQuote(dest.string()) + ";";
+    if(auto rt = db.execute(sql.c_str()); !rt.has_value())
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Failed to back up database: {}", mw::errorMsg(rt.error()))));
+    }
+    spdlog::info("Database backed up to {}", dest.string());
+    return dest;
+}
+
+} // namespace overseer::db
diff --git a/src/db/backup.hpp b/src/db/backup.hpp
new file mode 100644
index 0000000..728960a
--- /dev/null
+++ b/src/db/backup.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include <filesystem>
+#include <string>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+namespace overseer::db
+{
+
+/// Create an online backup of the SQLite database at `db_path`, using
+/// SQLite's `sqlite3_backup_*` API on the open connection `db`. The
+/// destination path is `db_path + ".bak-" + YYYYmmdd-HHMMSS`.
+///
+/// Returns the path of the backup file on success.
+mw::E<std::filesystem::path>
+backupDatabaseFile(mw::SQLite& db, const std::string& db_path);
+
+} // namespace overseer::db
diff --git a/src/db/migration_001_init.cpp b/src/db/migration_001_init.cpp
new file mode 100644
index 0000000..3bc2739
--- /dev/null
+++ b/src/db/migration_001_init.cpp
@@ -0,0 +1,159 @@
+#include "migrations.hpp"
+
+#include <array>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+
+namespace overseer::db
+{
+
+namespace
+{
+
+// One element per SQL statement. CREATE TRIGGER bodies contain
+// semicolons (between statements in their BEGIN…END block), so we keep
+// each trigger as a single string here rather than splitting on ';'.
+constexpr std::array<const char*, 17> SCHEMA_V1_STATEMENTS = {
+    // ---- attachment ---------------------------------------------------
+    R"SQL(
+CREATE TABLE attachment
+(
+    id      INTEGER PRIMARY KEY AUTOINCREMENT,
+    mime    TEXT    NOT NULL,
+    sha256  TEXT    NOT NULL UNIQUE,
+    bytes   BLOB    NOT NULL
+);
+)SQL",
+
+    // ---- storage ------------------------------------------------------
+    R"SQL(
+CREATE TABLE storage
+(
+    id            INTEGER PRIMARY KEY AUTOINCREMENT,
+    parent_id     INTEGER NULL REFERENCES storage(id) ON DELETE RESTRICT,
+    name          TEXT    NOT NULL,
+    description   TEXT    NULL,
+    attachment_id INTEGER NULL REFERENCES attachment(id) ON DELETE SET NULL,
+    UNIQUE(parent_id, name)
+);
+)SQL",
+    R"SQL(
+CREATE UNIQUE INDEX storage_root_name_unique
+    ON storage(name) WHERE parent_id IS NULL;
+)SQL",
+    "CREATE INDEX storage_parent_idx ON storage(parent_id);",
+
+    // ---- stuff --------------------------------------------------------
+    R"SQL(
+CREATE TABLE stuff
+(
+    id            INTEGER PRIMARY KEY AUTOINCREMENT,
+    name          TEXT    NOT NULL,
+    description   TEXT    NULL,
+    attachment_id INTEGER NULL REFERENCES attachment(id) ON DELETE SET NULL,
+    storage_id    INTEGER NOT NULL REFERENCES storage(id) ON DELETE RESTRICT
+);
+)SQL",
+    "CREATE INDEX stuff_storage_idx ON stuff(storage_id);",
+
+    // ---- stuff_move + trim trigger ------------------------------------
+    R"SQL(
+CREATE TABLE stuff_move
+(
+    id         INTEGER PRIMARY KEY AUTOINCREMENT,
+    stuff_id   INTEGER NOT NULL REFERENCES stuff(id) ON DELETE CASCADE,
+    storage_id INTEGER NOT NULL REFERENCES storage(id) ON DELETE RESTRICT,
+    moved_at   INTEGER NOT NULL
+);
+)SQL",
+    R"SQL(
+CREATE INDEX stuff_move_stuff_idx ON stuff_move(stuff_id, moved_at DESC);
+)SQL",
+    R"SQL(
+CREATE TRIGGER stuff_move_trim
+AFTER INSERT ON stuff_move
+BEGIN
+    DELETE FROM stuff_move
+    WHERE stuff_id = NEW.stuff_id
+      AND id IN (
+        SELECT id FROM stuff_move
+         WHERE stuff_id = NEW.stuff_id
+         ORDER BY moved_at DESC, id DESC
+         LIMIT -1 OFFSET 10
+      );
+END;
+)SQL",
+
+    // ---- FTS5 virtual tables ------------------------------------------
+    R"SQL(
+CREATE VIRTUAL TABLE storage_fts USING fts5(
+    name, description,
+    content='storage', content_rowid='id',
+    tokenize='unicode61 remove_diacritics 2'
+);
+)SQL",
+    R"SQL(
+CREATE VIRTUAL TABLE stuff_fts USING fts5(
+    name, description,
+    content='stuff', content_rowid='id',
+    tokenize='unicode61 remove_diacritics 2'
+);
+)SQL",
+
+    // ---- FTS sync triggers --------------------------------------------
+    R"SQL(
+CREATE TRIGGER storage_ai AFTER INSERT ON storage BEGIN
+    INSERT INTO storage_fts(rowid, name, description)
+    VALUES (NEW.id, NEW.name, NEW.description);
+END;
+)SQL",
+    R"SQL(
+CREATE TRIGGER storage_ad AFTER DELETE ON storage BEGIN
+    INSERT INTO storage_fts(storage_fts, rowid, name, description)
+    VALUES ('delete', OLD.id, OLD.name, OLD.description);
+END;
+)SQL",
+    R"SQL(
+CREATE TRIGGER storage_au AFTER UPDATE ON storage BEGIN
+    INSERT INTO storage_fts(storage_fts, rowid, name, description)
+    VALUES ('delete', OLD.id, OLD.name, OLD.description);
+    INSERT INTO storage_fts(rowid, name, description)
+    VALUES (NEW.id, NEW.name, NEW.description);
+END;
+)SQL",
+    R"SQL(
+CREATE TRIGGER stuff_ai AFTER INSERT ON stuff BEGIN
+    INSERT INTO stuff_fts(rowid, name, description)
+    VALUES (NEW.id, NEW.name, NEW.description);
+END;
+)SQL",
+    R"SQL(
+CREATE TRIGGER stuff_ad AFTER DELETE ON stuff BEGIN
+    INSERT INTO stuff_fts(stuff_fts, rowid, name, description)
+    VALUES ('delete', OLD.id, OLD.name, OLD.description);
+END;
+)SQL",
+    R"SQL(
+CREATE TRIGGER stuff_au AFTER UPDATE ON stuff BEGIN
+    INSERT INTO stuff_fts(stuff_fts, rowid, name, description)
+    VALUES ('delete', OLD.id, OLD.name, OLD.description);
+    INSERT INTO stuff_fts(rowid, name, description)
+    VALUES (NEW.id, NEW.name, NEW.description);
+END;
+)SQL",
+};
+
+} // namespace
+
+mw::E<void> migrateDB0To1(mw::SQLite& db)
+{
+    for(const char* stmt : SCHEMA_V1_STATEMENTS)
+    {
+        DO_OR_RETURN(db.execute(stmt));
+    }
+    return {};
+}
+
+} // namespace overseer::db
diff --git a/src/db/migrations.cpp b/src/db/migrations.cpp
new file mode 100644
index 0000000..490193e
--- /dev/null
+++ b/src/db/migrations.cpp
@@ -0,0 +1,129 @@
+#include "migrations.hpp"
+
+#include <cstdint>
+#include <format>
+#include <string>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+#include <spdlog/spdlog.h>
+
+#include "backup.hpp"
+
+namespace overseer::db
+{
+
+namespace
+{
+
+struct MigrationStep
+{
+    int64_t from;
+    int64_t to;
+    mw::E<void> (*fn)(mw::SQLite&);
+};
+
+const std::vector<MigrationStep>& registry()
+{
+    static const std::vector<MigrationStep> r = {
+        {0, 1, &migrateDB0To1},
+    };
+    return r;
+}
+
+mw::E<int64_t> getUserVersion(mw::SQLite& db)
+{
+    auto rt = db.evalToValue<int64_t>("PRAGMA user_version;");
+    if(!rt.has_value())
+    {
+        return std::unexpected(rt.error());
+    }
+    return *rt;
+}
+
+mw::E<void> setUserVersion(mw::SQLite& db, int64_t v)
+{
+    // PRAGMA does not accept bound parameters, so format directly.
+    // `v` is an internal integer; safe to format.
+    return db.execute(std::format("PRAGMA user_version = {};", v));
+}
+
+} // namespace
+
+mw::E<void> migrateIfNeeded(mw::SQLite& db, const std::string& db_path)
+{
+    auto current_r = getUserVersion(db);
+    if(!current_r.has_value())
+    {
+        return std::unexpected(current_r.error());
+    }
+    const int64_t current = *current_r;
+    const int64_t target = HIGHEST_KNOWN_VERSION;
+
+    if(current == target)
+    {
+        spdlog::info("Database schema is at version {} (current).", current);
+        return {};
+    }
+    if(current > target)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Database schema version {} is newer than this binary "
+            "knows about (target {}). Refusing to start.",
+            current, target)));
+    }
+
+    spdlog::info("Migrating database schema from version {} to {}.",
+                 current, target);
+
+    // Take ONE backup before any migration runs.
+    if(!db_path.empty())
+    {
+        if(auto bk = backupDatabaseFile(db, db_path); !bk.has_value())
+        {
+            return std::unexpected(bk.error());
+        }
+    }
+
+    for(const auto& step : registry())
+    {
+        if(step.from < current)
+        {
+            continue;
+        }
+        if(step.from >= target)
+        {
+            break;
+        }
+
+        spdlog::info("Applying migration {} -> {}.", step.from, step.to);
+
+        if(auto rt = db.execute("BEGIN IMMEDIATE;"); !rt.has_value())
+        {
+            return std::unexpected(rt.error());
+        }
+        if(auto rt = step.fn(db); !rt.has_value())
+        {
+            // best-effort rollback; ignore its error
+            (void)db.execute("ROLLBACK;");
+            return std::unexpected(rt.error());
+        }
+        if(auto rt = setUserVersion(db, step.to); !rt.has_value())
+        {
+            (void)db.execute("ROLLBACK;");
+            return std::unexpected(rt.error());
+        }
+        if(auto rt = db.execute("COMMIT;"); !rt.has_value())
+        {
+            (void)db.execute("ROLLBACK;");
+            return std::unexpected(rt.error());
+        }
+    }
+
+    spdlog::info("Database migration complete.");
+    return {};
+}
+
+} // namespace overseer::db
diff --git a/src/db/migrations.hpp b/src/db/migrations.hpp
new file mode 100644
index 0000000..fa291b8
--- /dev/null
+++ b/src/db/migrations.hpp
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstdint>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+namespace overseer::db
+{
+
+/// The highest schema version this binary knows about. Increment when
+/// adding a new migration.
+inline constexpr int64_t HIGHEST_KNOWN_VERSION = 1;
+
+/// Run all required migrations from the current user_version up to
+/// HIGHEST_KNOWN_VERSION. If the DB is already at the target, do
+/// nothing. If it is *newer* than the binary, refuse and return an
+/// error. Before any migration is run, takes a backup of the DB file.
+///
+/// `db_path` is the on-disk path of the DB file (needed for the
+/// backup); pass it even though `db` is the live connection.
+mw::E<void> migrateIfNeeded(mw::SQLite& db, const std::string& db_path);
+
+// ---- individual migrations ------------------------------------------
+
+mw::E<void> migrateDB0To1(mw::SQLite& db);
+
+} // namespace overseer::db
diff --git a/src/inventory/include/inventory/attachment.hpp b/src/inventory/include/inventory/attachment.hpp
new file mode 100644
index 0000000..56549d9
--- /dev/null
+++ b/src/inventory/include/inventory/attachment.hpp
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace overseer::inventory
+{
+
+struct Attachment
+{
+    int64_t id = 0;
+    std::string mime;
+    std::string sha256;
+    std::vector<unsigned char> bytes;
+};
+
+} // namespace overseer::inventory
diff --git a/src/inventory/include/inventory/repo.hpp b/src/inventory/include/inventory/repo.hpp
new file mode 100644
index 0000000..8308646
--- /dev/null
+++ b/src/inventory/include/inventory/repo.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+#include "attachment.hpp"
+#include "storage.hpp"
+#include "stuff.hpp"
+
+namespace overseer::inventory
+{
+
+/// Sentinel error messages exposed by InventoryRepo so the HTTP layer
+/// can map them to specific status codes / messages.
+inline constexpr const char* ERR_NOT_FOUND   = "NOT_FOUND";
+inline constexpr const char* ERR_NOT_EMPTY   = "NOT_EMPTY";   // delete blocked
+inline constexpr const char* ERR_NAME_TAKEN  = "NAME_TAKEN";  // sibling collision
+inline constexpr const char* ERR_BAD_INPUT   = "BAD_INPUT";
+
+class InventoryRepo
+{
+public:
+    explicit InventoryRepo(mw::SQLite& db);
+
+    // ---- storages ----------------------------------------------------
+    mw::E<int64_t>              createStorage(const Storage& s);
+    mw::E<Storage>              getStorage(int64_t id);
+    mw::E<std::vector<Storage>> listChildren(std::optional<int64_t> parent_id);
+    mw::E<std::vector<Storage>> pathTo(int64_t id);
+    mw::E<void>                 updateStorage(const Storage& s);
+    mw::E<void>                 deleteStorage(int64_t id);
+
+    // ---- stuffs ------------------------------------------------------
+    mw::E<int64_t>              createStuff(const Stuff& s);
+    mw::E<Stuff>                getStuff(int64_t id);
+    mw::E<std::vector<Stuff>>   listInStorage(int64_t storage_id);
+    mw::E<void>                 updateStuff(const Stuff& s);
+    mw::E<void>                 moveStuff(int64_t stuff_id,
+                                          int64_t new_storage_id);
+    mw::E<void>                 deleteStuff(int64_t id);
+    mw::E<std::vector<StuffMove>> recentMoves(int64_t stuff_id);
+
+    // ---- search ------------------------------------------------------
+    mw::E<std::vector<Storage>> searchStorages(std::string_view q);
+    mw::E<std::vector<Stuff>>   searchStuffs(std::string_view q);
+
+    // ---- attachments -------------------------------------------------
+    mw::E<int64_t> putAttachment(std::span<const unsigned char> bytes,
+                                 std::string_view source_mime);
+    mw::E<Attachment> getAttachment(int64_t id);
+
+private:
+    mw::SQLite& db_;
+};
+
+} // namespace overseer::inventory
diff --git a/src/inventory/include/inventory/storage.hpp b/src/inventory/include/inventory/storage.hpp
new file mode 100644
index 0000000..ebbb002
--- /dev/null
+++ b/src/inventory/include/inventory/storage.hpp
@@ -0,0 +1,19 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+namespace overseer::inventory
+{
+
+struct Storage
+{
+    int64_t id = 0;
+    std::optional<int64_t> parent_id;
+    std::string name;
+    std::optional<std::string> description;
+    std::optional<int64_t> attachment_id;
+};
+
+} // namespace overseer::inventory
diff --git a/src/inventory/include/inventory/stuff.hpp b/src/inventory/include/inventory/stuff.hpp
new file mode 100644
index 0000000..d7ae6c8
--- /dev/null
+++ b/src/inventory/include/inventory/stuff.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include <mw/utils.hpp>
+
+namespace overseer::inventory
+{
+
+struct Stuff
+{
+    int64_t id = 0;
+    std::string name;
+    std::optional<std::string> description;
+    std::optional<int64_t> attachment_id;
+    int64_t storage_id = 0;
+};
+
+struct StuffMove
+{
+    int64_t storage_id = 0;
+    mw::Time moved_at;
+};
+
+} // namespace overseer::inventory
diff --git a/src/inventory/src/attachment.cpp b/src/inventory/src/attachment.cpp
new file mode 100644
index 0000000..3906dce
--- /dev/null
+++ b/src/inventory/src/attachment.cpp
@@ -0,0 +1,200 @@
+#include "inventory/repo.hpp"
+
+#include <cstddef>
+#include <cstdint>
+#include <format>
+#include <span>
+#include <sstream>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <Magick++.h>
+#include <mw/crypto.hpp>
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <sqlite3.h>
+
+namespace overseer::inventory
+{
+
+namespace
+{
+
+constexpr size_t MAX_LONG_SIDE = 1024;
+constexpr int    AVIF_QUALITY  = 60;
+
+mw::E<std::vector<unsigned char>>
+processToAvif(std::span<const unsigned char> bytes)
+{
+    Magick::Image img;
+    try
+    {
+        Magick::Blob in_blob(bytes.data(), bytes.size());
+        img.read(in_blob);
+    }
+    catch(const Magick::Exception& e)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Cannot decode image: {}", e.what())));
+    }
+
+    try
+    {
+        const size_t w = img.columns();
+        const size_t h = img.rows();
+        const size_t longest = (w > h) ? w : h;
+        if(longest > MAX_LONG_SIDE)
+        {
+            double scale = static_cast<double>(MAX_LONG_SIDE) / longest;
+            size_t nw = static_cast<size_t>(w * scale + 0.5);
+            size_t nh = static_cast<size_t>(h * scale + 0.5);
+            img.resize(Magick::Geometry(nw, nh));
+        }
+        img.strip();
+        img.magick("AVIF");
+        img.quality(AVIF_QUALITY);
+
+        Magick::Blob out_blob;
+        img.write(&out_blob);
+
+        const auto* p = static_cast<const unsigned char*>(out_blob.data());
+        return std::vector<unsigned char>(p, p + out_blob.length());
+    }
+    catch(const Magick::Exception& e)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Failed to encode AVIF: {}", e.what())));
+    }
+}
+
+mw::E<std::string> sha256Hex(const std::vector<unsigned char>& bytes)
+{
+    mw::SHA256Hasher hasher;
+    // mw::HasherInterface::hashToHexStr takes const std::string&, but
+    // a std::string can hold arbitrary bytes. Construct one from the
+    // vector and pass it through.
+    std::string s(reinterpret_cast<const char*>(bytes.data()),
+                  bytes.size());
+    return hasher.hashToHexStr(s);
+}
+
+} // namespace
+
+mw::E<int64_t>
+InventoryRepo::putAttachment(std::span<const unsigned char> bytes,
+                             [[maybe_unused]] std::string_view source_mime)
+{
+    if(bytes.empty())
+    {
+        return std::unexpected(mw::runtimeError(ERR_BAD_INPUT));
+    }
+
+    auto processed_r = processToAvif(bytes);
+    if(!processed_r.has_value())
+    {
+        return std::unexpected(processed_r.error());
+    }
+    const auto& processed = *processed_r;
+
+    auto sha_r = sha256Hex(processed);
+    if(!sha_r.has_value())
+    {
+        return std::unexpected(sha_r.error());
+    }
+    const std::string& sha = *sha_r;
+
+    // Dedupe lookup.
+    {
+        auto stmt_r = db_.statementFromStr(
+            "SELECT id FROM attachment WHERE sha256 = ?;");
+        if(!stmt_r.has_value())
+        {
+            return std::unexpected(stmt_r.error());
+        }
+        if(auto b = stmt_r->bind(sha); !b.has_value())
+        {
+            return std::unexpected(b.error());
+        }
+        auto rows = db_.eval<int64_t>(std::move(*stmt_r));
+        if(!rows.has_value())
+        {
+            return std::unexpected(rows.error());
+        }
+        if(!rows->empty())
+        {
+            return std::get<0>(rows->front());
+        }
+    }
+
+    // Insert (mw::SQLite has no BLOB binding, so use sqlite3 directly
+    // on the prepared statement's underlying handle.)
+    auto stmt_r = db_.statementFromStr(
+        "INSERT INTO attachment(mime, sha256, bytes) VALUES (?, ?, ?);");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    auto& stmt = *stmt_r;
+    const std::string mime = "image/avif";
+    if(auto b = stmt.bind(mime, sha); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(int rc = sqlite3_bind_blob(stmt.data(), 3,
+                                  processed.data(),
+                                  static_cast<int>(processed.size()),
+                                  SQLITE_TRANSIENT);
+       rc != SQLITE_OK)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Failed to bind blob: {}", sqlite3_errstr(rc))));
+    }
+    if(auto r = db_.execute(std::move(stmt)); !r.has_value())
+    {
+        return std::unexpected(r.error());
+    }
+    return db_.lastInsertRowID();
+}
+
+mw::E<Attachment> InventoryRepo::getAttachment(int64_t id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "SELECT mime, sha256, bytes FROM attachment WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    auto& stmt = *stmt_r;
+    if(auto b = stmt.bind(id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+
+    int rc = sqlite3_step(stmt.data());
+    if(rc == SQLITE_DONE)
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    if(rc != SQLITE_ROW)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Failed to fetch attachment: {}", sqlite3_errstr(rc))));
+    }
+
+    Attachment a;
+    a.id = id;
+    const auto* mime_p = reinterpret_cast<const char*>(
+        sqlite3_column_text(stmt.data(), 0));
+    a.mime = mime_p ? mime_p : "";
+    const auto* sha_p = reinterpret_cast<const char*>(
+        sqlite3_column_text(stmt.data(), 1));
+    a.sha256 = sha_p ? sha_p : "";
+    const void* blob = sqlite3_column_blob(stmt.data(), 2);
+    int nbytes = sqlite3_column_bytes(stmt.data(), 2);
+    a.bytes.assign(static_cast<const unsigned char*>(blob),
+                   static_cast<const unsigned char*>(blob) + nbytes);
+    return a;
+}
+
+} // namespace overseer::inventory
diff --git a/src/inventory/src/internal.hpp b/src/inventory/src/internal.hpp
new file mode 100644
index 0000000..9701bed
--- /dev/null
+++ b/src/inventory/src/internal.hpp
@@ -0,0 +1,21 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+
+namespace overseer::inventory::internal
+{
+
+// Inspect an mw::E<T> error message for an embedded SQLite text that
+// would tell us a constraint failed. We do not have structured error
+// codes from mw::SQLite, so we sniff the message text.
+inline bool errorContains(const mw::Error& e, std::string_view needle)
+{
+    return mw::errorMsg(e).find(needle) != std::string::npos;
+}
+
+} // namespace overseer::inventory::internal
diff --git a/src/inventory/src/repo.cpp b/src/inventory/src/repo.cpp
new file mode 100644
index 0000000..3827075
--- /dev/null
+++ b/src/inventory/src/repo.cpp
@@ -0,0 +1,10 @@
+#include "inventory/repo.hpp"
+
+#include <mw/database.hpp>
+
+namespace overseer::inventory
+{
+
+InventoryRepo::InventoryRepo(mw::SQLite& db) : db_(db) {}
+
+} // namespace overseer::inventory
diff --git a/src/inventory/src/search.cpp b/src/inventory/src/search.cpp
new file mode 100644
index 0000000..d816729
--- /dev/null
+++ b/src/inventory/src/search.cpp
@@ -0,0 +1,119 @@
+#include "inventory/repo.hpp"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+namespace overseer::inventory
+{
+
+namespace
+{
+
+// Quote the user's input as an FTS5 phrase plus a trailing prefix
+// wildcard. We escape internal double-quotes by doubling them per the
+// FTS5 syntax docs.
+std::string buildFts5Query(std::string_view q)
+{
+    std::string trimmed;
+    trimmed.reserve(q.size());
+    for(char c : q)
+    {
+        if(c == '"')
+        {
+            trimmed += "\"\"";
+        }
+        else
+        {
+            trimmed += c;
+        }
+    }
+    if(trimmed.empty())
+    {
+        return "";
+    }
+    return "\"" + trimmed + "\" *";
+}
+
+} // namespace
+
+mw::E<std::vector<Storage>> InventoryRepo::searchStorages(std::string_view q)
+{
+    std::vector<Storage> out;
+    const std::string match = buildFts5Query(q);
+    if(match.empty())
+    {
+        return out;
+    }
+    auto stmt_r = db_.statementFromStr(
+        "SELECT s.id, s.parent_id, s.name, s.description, s.attachment_id "
+        "FROM storage s JOIN storage_fts f ON f.rowid = s.id "
+        "WHERE storage_fts MATCH ? "
+        "ORDER BY rank LIMIT 50;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(match); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, std::optional<int64_t>, std::string,
+                           std::optional<std::string>,
+                           std::optional<int64_t>>(std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    out.reserve(rows_r->size());
+    for(const auto& [id, pid, name, desc, att] : *rows_r)
+    {
+        out.push_back(Storage{id, pid, name, desc, att});
+    }
+    return out;
+}
+
+mw::E<std::vector<Stuff>> InventoryRepo::searchStuffs(std::string_view q)
+{
+    std::vector<Stuff> out;
+    const std::string match = buildFts5Query(q);
+    if(match.empty())
+    {
+        return out;
+    }
+    auto stmt_r = db_.statementFromStr(
+        "SELECT s.id, s.name, s.description, s.attachment_id, s.storage_id "
+        "FROM stuff s JOIN stuff_fts f ON f.rowid = s.id "
+        "WHERE stuff_fts MATCH ? "
+        "ORDER BY rank LIMIT 50;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(match); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, std::string,
+                           std::optional<std::string>,
+                           std::optional<int64_t>, int64_t>(
+        std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    out.reserve(rows_r->size());
+    for(const auto& [id, name, desc, att, sid] : *rows_r)
+    {
+        out.push_back(Stuff{id, name, desc, att, sid});
+    }
+    return out;
+}
+
+} // namespace overseer::inventory
diff --git a/src/inventory/src/storage.cpp b/src/inventory/src/storage.cpp
new file mode 100644
index 0000000..7e5fc35
--- /dev/null
+++ b/src/inventory/src/storage.cpp
@@ -0,0 +1,258 @@
+#include "inventory/repo.hpp"
+
+#include <algorithm>
+#include <cstdint>
+#include <format>
+#include <optional>
+#include <string>
+#include <tuple>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+
+#include "internal.hpp"
+
+namespace overseer::inventory
+{
+
+namespace
+{
+
+Storage tupleToStorage(
+    const std::tuple<int64_t, std::optional<int64_t>, std::string,
+                     std::optional<std::string>, std::optional<int64_t>>& row)
+{
+    Storage s;
+    s.id            = std::get<0>(row);
+    s.parent_id     = std::get<1>(row);
+    s.name          = std::get<2>(row);
+    s.description   = std::get<3>(row);
+    s.attachment_id = std::get<4>(row);
+    return s;
+}
+
+} // namespace
+
+mw::E<int64_t> InventoryRepo::createStorage(const Storage& s)
+{
+    if(s.name.empty())
+    {
+        return std::unexpected(mw::runtimeError(ERR_BAD_INPUT));
+    }
+    auto stmt_r = db_.statementFromStr(
+        "INSERT INTO storage(parent_id, name, description, attachment_id) "
+        "VALUES (?, ?, ?, ?);");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    auto& stmt = *stmt_r;
+    if(auto b = stmt.bind(s.parent_id, s.name, s.description,
+                          s.attachment_id);
+       !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(auto r = db_.execute(std::move(stmt)); !r.has_value())
+    {
+        // libmw surfaces SQLITE_CONSTRAINT via sqlite3_errstr() which
+        // returns "constraint failed" without distinguishing UNIQUE
+        // from FK. For storage inserts/updates the only constraint
+        // that fires in practice is the sibling-name UNIQUE; map it
+        // to ERR_NAME_TAKEN.
+        if(internal::errorContains(r.error(), "constraint"))
+        {
+            return std::unexpected(mw::runtimeError(ERR_NAME_TAKEN));
+        }
+        return std::unexpected(r.error());
+    }
+    return db_.lastInsertRowID();
+}
+
+mw::E<Storage> InventoryRepo::getStorage(int64_t id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "SELECT id, parent_id, name, description, attachment_id "
+        "FROM storage WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, std::optional<int64_t>, std::string,
+                           std::optional<std::string>,
+                           std::optional<int64_t>>(std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    if(rows_r->empty())
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    return tupleToStorage(rows_r->front());
+}
+
+mw::E<std::vector<Storage>>
+InventoryRepo::listChildren(std::optional<int64_t> parent_id)
+{
+    mw::E<std::vector<std::tuple<
+        int64_t, std::optional<int64_t>, std::string,
+        std::optional<std::string>, std::optional<int64_t>>>> rows_r;
+
+    if(parent_id.has_value())
+    {
+        auto stmt_r = db_.statementFromStr(
+            "SELECT id, parent_id, name, description, attachment_id "
+            "FROM storage WHERE parent_id = ? ORDER BY name;");
+        if(!stmt_r.has_value())
+        {
+            return std::unexpected(stmt_r.error());
+        }
+        if(auto b = stmt_r->bind(*parent_id); !b.has_value())
+        {
+            return std::unexpected(b.error());
+        }
+        rows_r = db_.eval<int64_t, std::optional<int64_t>, std::string,
+                          std::optional<std::string>,
+                          std::optional<int64_t>>(std::move(*stmt_r));
+    }
+    else
+    {
+        rows_r = db_.eval<int64_t, std::optional<int64_t>, std::string,
+                          std::optional<std::string>,
+                          std::optional<int64_t>>(
+            "SELECT id, parent_id, name, description, attachment_id "
+            "FROM storage WHERE parent_id IS NULL ORDER BY name;");
+    }
+
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    std::vector<Storage> result;
+    result.reserve(rows_r->size());
+    for(const auto& r : *rows_r)
+    {
+        result.push_back(tupleToStorage(r));
+    }
+    return result;
+}
+
+mw::E<std::vector<Storage>> InventoryRepo::pathTo(int64_t id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "WITH RECURSIVE path(id, parent_id, name, description, "
+        "attachment_id, depth) AS ("
+        "  SELECT id, parent_id, name, description, attachment_id, 0 "
+        "    FROM storage WHERE id = ?"
+        "  UNION ALL "
+        "  SELECT s.id, s.parent_id, s.name, s.description, "
+        "         s.attachment_id, p.depth + 1 "
+        "    FROM storage s JOIN path p ON s.id = p.parent_id"
+        ") "
+        "SELECT id, parent_id, name, description, attachment_id "
+        "FROM path ORDER BY depth DESC;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, std::optional<int64_t>, std::string,
+                           std::optional<std::string>,
+                           std::optional<int64_t>>(std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    if(rows_r->empty())
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    std::vector<Storage> result;
+    result.reserve(rows_r->size());
+    for(const auto& r : *rows_r)
+    {
+        result.push_back(tupleToStorage(r));
+    }
+    return result;
+}
+
+mw::E<void> InventoryRepo::updateStorage(const Storage& s)
+{
+    if(s.id == 0 || s.name.empty())
+    {
+        return std::unexpected(mw::runtimeError(ERR_BAD_INPUT));
+    }
+    auto stmt_r = db_.statementFromStr(
+        "UPDATE storage "
+        "SET parent_id = ?, name = ?, description = ?, attachment_id = ? "
+        "WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(s.parent_id, s.name, s.description,
+                             s.attachment_id, s.id);
+       !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(auto r = db_.execute(std::move(*stmt_r)); !r.has_value())
+    {
+        // libmw surfaces SQLITE_CONSTRAINT via sqlite3_errstr() which
+        // returns "constraint failed" without distinguishing UNIQUE
+        // from FK. For storage inserts/updates the only constraint
+        // that fires in practice is the sibling-name UNIQUE; map it
+        // to ERR_NAME_TAKEN.
+        if(internal::errorContains(r.error(), "constraint"))
+        {
+            return std::unexpected(mw::runtimeError(ERR_NAME_TAKEN));
+        }
+        return std::unexpected(r.error());
+    }
+    if(db_.changedRowsCount() == 0)
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    return {};
+}
+
+mw::E<void> InventoryRepo::deleteStorage(int64_t id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "DELETE FROM storage WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(auto r = db_.execute(std::move(*stmt_r)); !r.has_value())
+    {
+        // FK RESTRICT message contains "FOREIGN KEY constraint failed"
+        if(internal::errorContains(r.error(), "FOREIGN KEY") ||
+           internal::errorContains(r.error(), "constraint"))
+        {
+            return std::unexpected(mw::runtimeError(ERR_NOT_EMPTY));
+        }
+        return std::unexpected(r.error());
+    }
+    if(db_.changedRowsCount() == 0)
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    return {};
+}
+
+} // namespace overseer::inventory
diff --git a/src/inventory/src/stuff.cpp b/src/inventory/src/stuff.cpp
new file mode 100644
index 0000000..e101b49
--- /dev/null
+++ b/src/inventory/src/stuff.cpp
@@ -0,0 +1,284 @@
+#include "inventory/repo.hpp"
+
+#include <chrono>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <tuple>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+
+#include "internal.hpp"
+
+namespace overseer::inventory
+{
+
+namespace
+{
+
+Stuff tupleToStuff(
+    const std::tuple<int64_t, std::string, std::optional<std::string>,
+                     std::optional<int64_t>, int64_t>& row)
+{
+    Stuff s;
+    s.id            = std::get<0>(row);
+    s.name          = std::get<1>(row);
+    s.description   = std::get<2>(row);
+    s.attachment_id = std::get<3>(row);
+    s.storage_id    = std::get<4>(row);
+    return s;
+}
+
+} // namespace
+
+mw::E<int64_t> InventoryRepo::createStuff(const Stuff& s)
+{
+    if(s.name.empty() || s.storage_id == 0)
+    {
+        return std::unexpected(mw::runtimeError(ERR_BAD_INPUT));
+    }
+    auto stmt_r = db_.statementFromStr(
+        "INSERT INTO stuff(name, description, attachment_id, storage_id) "
+        "VALUES (?, ?, ?, ?);");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(s.name, s.description, s.attachment_id,
+                             s.storage_id);
+       !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(auto r = db_.execute(std::move(*stmt_r)); !r.has_value())
+    {
+        return std::unexpected(r.error());
+    }
+    return db_.lastInsertRowID();
+}
+
+mw::E<Stuff> InventoryRepo::getStuff(int64_t id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "SELECT id, name, description, attachment_id, storage_id "
+        "FROM stuff WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, std::string, std::optional<std::string>,
+                           std::optional<int64_t>, int64_t>(
+        std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    if(rows_r->empty())
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    return tupleToStuff(rows_r->front());
+}
+
+mw::E<std::vector<Stuff>> InventoryRepo::listInStorage(int64_t storage_id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "SELECT id, name, description, attachment_id, storage_id "
+        "FROM stuff WHERE storage_id = ? ORDER BY name;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(storage_id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, std::string, std::optional<std::string>,
+                           std::optional<int64_t>, int64_t>(
+        std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    std::vector<Stuff> result;
+    result.reserve(rows_r->size());
+    for(const auto& r : *rows_r)
+    {
+        result.push_back(tupleToStuff(r));
+    }
+    return result;
+}
+
+mw::E<void> InventoryRepo::updateStuff(const Stuff& s)
+{
+    if(s.id == 0 || s.name.empty() || s.storage_id == 0)
+    {
+        return std::unexpected(mw::runtimeError(ERR_BAD_INPUT));
+    }
+    // We deliberately do NOT change storage_id here; that goes via
+    // moveStuff() so the move history is updated. If the caller passes
+    // a different storage_id, we route to moveStuff first.
+    auto current_r = getStuff(s.id);
+    if(!current_r.has_value())
+    {
+        return std::unexpected(current_r.error());
+    }
+    if(current_r->storage_id != s.storage_id)
+    {
+        if(auto m = moveStuff(s.id, s.storage_id); !m.has_value())
+        {
+            return std::unexpected(m.error());
+        }
+    }
+    auto stmt_r = db_.statementFromStr(
+        "UPDATE stuff "
+        "SET name = ?, description = ?, attachment_id = ? "
+        "WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(s.name, s.description, s.attachment_id, s.id);
+       !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(auto r = db_.execute(std::move(*stmt_r)); !r.has_value())
+    {
+        return std::unexpected(r.error());
+    }
+    return {};
+}
+
+mw::E<void> InventoryRepo::moveStuff(int64_t stuff_id, int64_t new_storage_id)
+{
+    if(auto r = db_.execute("BEGIN IMMEDIATE;"); !r.has_value())
+    {
+        return std::unexpected(r.error());
+    }
+
+    auto rollback = [&](mw::Error e) -> mw::E<void>
+    {
+        (void)db_.execute("ROLLBACK;");
+        return std::unexpected(std::move(e));
+    };
+
+    auto sel_r = db_.statementFromStr(
+        "SELECT storage_id FROM stuff WHERE id = ?;");
+    if(!sel_r.has_value())
+    {
+        return rollback(sel_r.error());
+    }
+    if(auto b = sel_r->bind(stuff_id); !b.has_value())
+    {
+        return rollback(b.error());
+    }
+    auto cur_r = db_.evalToValue<int64_t>(std::move(*sel_r));
+    if(!cur_r.has_value())
+    {
+        return rollback(cur_r.error());
+    }
+    const int64_t cur_storage = *cur_r;
+    if(cur_storage == new_storage_id)
+    {
+        (void)db_.execute("COMMIT;");
+        return {};
+    }
+
+    auto ins_r = db_.statementFromStr(
+        "INSERT INTO stuff_move(stuff_id, storage_id, moved_at) "
+        "VALUES (?, ?, ?);");
+    if(!ins_r.has_value())
+    {
+        return rollback(ins_r.error());
+    }
+    const int64_t now =
+        mw::timeToSeconds(std::chrono::system_clock::now());
+    if(auto b = ins_r->bind(stuff_id, cur_storage, now); !b.has_value())
+    {
+        return rollback(b.error());
+    }
+    if(auto r = db_.execute(std::move(*ins_r)); !r.has_value())
+    {
+        return rollback(r.error());
+    }
+
+    auto upd_r = db_.statementFromStr(
+        "UPDATE stuff SET storage_id = ? WHERE id = ?;");
+    if(!upd_r.has_value())
+    {
+        return rollback(upd_r.error());
+    }
+    if(auto b = upd_r->bind(new_storage_id, stuff_id); !b.has_value())
+    {
+        return rollback(b.error());
+    }
+    if(auto r = db_.execute(std::move(*upd_r)); !r.has_value())
+    {
+        return rollback(r.error());
+    }
+
+    if(auto r = db_.execute("COMMIT;"); !r.has_value())
+    {
+        return rollback(r.error());
+    }
+    return {};
+}
+
+mw::E<void> InventoryRepo::deleteStuff(int64_t id)
+{
+    auto stmt_r = db_.statementFromStr("DELETE FROM stuff WHERE id = ?;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    if(auto r = db_.execute(std::move(*stmt_r)); !r.has_value())
+    {
+        return std::unexpected(r.error());
+    }
+    if(db_.changedRowsCount() == 0)
+    {
+        return std::unexpected(mw::runtimeError(ERR_NOT_FOUND));
+    }
+    return {};
+}
+
+mw::E<std::vector<StuffMove>> InventoryRepo::recentMoves(int64_t stuff_id)
+{
+    auto stmt_r = db_.statementFromStr(
+        "SELECT storage_id, moved_at FROM stuff_move "
+        "WHERE stuff_id = ? ORDER BY moved_at DESC, id DESC LIMIT 10;");
+    if(!stmt_r.has_value())
+    {
+        return std::unexpected(stmt_r.error());
+    }
+    if(auto b = stmt_r->bind(stuff_id); !b.has_value())
+    {
+        return std::unexpected(b.error());
+    }
+    auto rows_r = db_.eval<int64_t, int64_t>(std::move(*stmt_r));
+    if(!rows_r.has_value())
+    {
+        return std::unexpected(rows_r.error());
+    }
+    std::vector<StuffMove> out;
+    out.reserve(rows_r->size());
+    for(const auto& [sid, ts] : *rows_r)
+    {
+        out.push_back({sid, mw::secondsToTime(ts)});
+    }
+    return out;
+}
+
+} // namespace overseer::inventory
diff --git a/src/overseer/attachment_handler.cpp b/src/overseer/attachment_handler.cpp
new file mode 100644
index 0000000..3cb6c2c
--- /dev/null
+++ b/src/overseer/attachment_handler.cpp
@@ -0,0 +1,59 @@
+#include "attachment_handler.hpp"
+
+#include <format>
+#include <string>
+
+#include <httplib.h>
+#include <mw/error.hpp>
+#include <spdlog/spdlog.h>
+
+namespace overseer
+{
+
+AttachmentHandler::AttachmentHandler(inventory::InventoryRepo& repo)
+    : repo_(repo)
+{}
+
+void AttachmentHandler::registerRoutes(httplib::Server& s)
+{
+    s.Get(R"(/attachment/(\d+))",
+          [this](const httplib::Request& req, httplib::Response& res)
+    {
+        int64_t id = 0;
+        try
+        {
+            id = std::stoll(req.matches[1].str());
+        }
+        catch(...)
+        {
+            res.status = 404;
+            return;
+        }
+
+        auto a_r = repo_.getAttachment(id);
+        if(!a_r.has_value())
+        {
+            res.status = 404;
+            res.set_content("Not found", "text/plain");
+            return;
+        }
+        const auto& a = *a_r;
+
+        // ETag handling.
+        std::string etag = "\"" + a.sha256 + "\"";
+        if(auto inm = req.get_header_value("If-None-Match");
+           !inm.empty() && inm == etag)
+        {
+            res.status = 304;
+            res.set_header("ETag", etag);
+            return;
+        }
+        res.set_header("ETag", etag);
+        res.set_header("Cache-Control",
+                       "private, max-age=31536000, immutable");
+        res.set_content(reinterpret_cast<const char*>(a.bytes.data()),
+                        a.bytes.size(), a.mime);
+    });
+}
+
+} // namespace overseer
diff --git a/src/overseer/attachment_handler.hpp b/src/overseer/attachment_handler.hpp
new file mode 100644
index 0000000..8e0156d
--- /dev/null
+++ b/src/overseer/attachment_handler.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include <httplib.h>
+
+#include "inventory/repo.hpp"
+
+namespace overseer
+{
+
+class AttachmentHandler
+{
+public:
+    explicit AttachmentHandler(inventory::InventoryRepo& repo);
+    void registerRoutes(httplib::Server& s);
+
+private:
+    inventory::InventoryRepo& repo_;
+};
+
+} // namespace overseer
diff --git a/src/overseer/auth_handler.cpp b/src/overseer/auth_handler.cpp
new file mode 100644
index 0000000..03ff4de
--- /dev/null
+++ b/src/overseer/auth_handler.cpp
@@ -0,0 +1,344 @@
+#include "auth_handler.hpp"
+
+#include <chrono>
+#include <format>
+#include <memory>
+#include <optional>
+#include <string>
+#include <thread>
+#include <utility>
+
+#include <httplib.h>
+#include <mw/auth.hpp>
+#include <mw/http_client.hpp>
+#include <mw/utils.hpp>
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace overseer
+{
+
+namespace
+{
+
+// Extract the value of cookie `name` from the request, or "" if not
+// present. We do not pull in a cookie parser; the format we care about
+// is simple.
+std::string getCookie(const httplib::Request& req, const std::string& name)
+{
+    auto h = req.get_header_value("Cookie");
+    if(h.empty())
+    {
+        return "";
+    }
+    std::string_view sv(h);
+    while(!sv.empty())
+    {
+        // Skip leading whitespace + ';'
+        size_t i = 0;
+        while(i < sv.size() && (sv[i] == ' ' || sv[i] == ';' ||
+                                sv[i] == '\t'))
+            ++i;
+        sv.remove_prefix(i);
+        if(sv.empty()) break;
+        size_t eq = sv.find('=');
+        if(eq == std::string_view::npos) break;
+        std::string_view key = sv.substr(0, eq);
+        size_t end = sv.find(';', eq + 1);
+        std::string_view val = (end == std::string_view::npos)
+            ? sv.substr(eq + 1)
+            : sv.substr(eq + 1, end - eq - 1);
+        if(key == name)
+        {
+            return std::string(val);
+        }
+        if(end == std::string_view::npos) break;
+        sv.remove_prefix(end + 1);
+    }
+    return "";
+}
+
+std::string buildSetCookie(const std::string& name, const std::string& value,
+                           std::chrono::minutes max_age, bool secure)
+{
+    std::string c = std::format(
+        "{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}",
+        name, value, max_age.count() * 60);
+    if(secure) c += "; Secure";
+    return c;
+}
+
+std::string clearCookie(const std::string& name)
+{
+    return std::format("{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
+                       name);
+}
+
+std::string parseQuery(const httplib::Request& req, const std::string& key)
+{
+    return req.has_param(key) ? req.get_param_value(key) : std::string{};
+}
+
+std::string paramsToQuery(const httplib::Params& p)
+{
+    std::string out;
+    for(const auto& [k, v] : p)
+    {
+        if(!out.empty()) out += "&";
+        out += mw::urlEncode(k) + "=" + mw::urlEncode(v);
+    }
+    return out;
+}
+
+} // namespace
+
+AuthHandler::AuthHandler(const Config& config,
+                         std::unique_ptr<mw::AuthInterface> auth,
+                         SessionStore& sessions)
+    : auth_(std::move(auth)),
+      sessions_(sessions),
+      cookie_name_(config.session.cookie_name),
+      max_age_(config.session.max_age),
+      issuer_url_(config.oidc.issuer_url)
+{
+    if(issuer_url_.empty())
+    {
+        // Tests / stubs may inject an AuthInterface with no real OIDC
+        // backing; skip discovery entirely.
+        return;
+    }
+    // libmw's AuthOpenIDConnect already fetches discovery at create()
+    // time but doesn't expose `end_session_endpoint`. Re-fetch here so
+    // logout can hit the IdP's RP-initiated logout endpoint.
+    try
+    {
+        mw::HTTPSession http;
+        std::string url = issuer_url_;
+        if(!url.empty() && url.back() == '/') url.pop_back();
+        url += "/.well-known/openid-configuration";
+        auto resp = http.get(url);
+        if(resp.has_value() && (*resp)->status == 200)
+        {
+            auto j = nlohmann::json::parse(
+                (*resp)->payloadAsStr(), nullptr, false);
+            if(j.is_object() && j.contains("end_session_endpoint")
+               && j["end_session_endpoint"].is_string())
+            {
+                end_session_endpoint_ =
+                    j["end_session_endpoint"].get<std::string>();
+                spdlog::info("OIDC end_session_endpoint: {}",
+                             end_session_endpoint_);
+            }
+            else
+            {
+                spdlog::info(
+                    "IdP discovery doc has no end_session_endpoint; "
+                    "logout will be local-only.");
+            }
+        }
+        else
+        {
+            spdlog::warn("OIDC discovery fetch returned non-200; "
+                         "logout will be local-only.");
+        }
+    }
+    catch(const std::exception& e)
+    {
+        spdlog::warn("OIDC discovery fetch failed: {}; "
+                     "logout will be local-only.", e.what());
+    }
+}
+
+std::optional<Session>
+AuthHandler::sessionForRequest(const httplib::Request& req) const
+{
+    auto sid = getCookie(req, cookie_name_);
+    if(sid.empty()) return std::nullopt;
+    auto s = sessions_.get(sid);
+    if(!s.has_value()) return std::nullopt;
+
+    // Lazy refresh: if the access token expires within 60s and we have
+    // a refresh token, swap them out now. On refresh failure, drop the
+    // session and force a re-login.
+    if(s->tokens.expiration.has_value() &&
+       s->tokens.refresh_token.has_value())
+    {
+        const auto now = std::chrono::system_clock::now();
+        if(*s->tokens.expiration - now < std::chrono::seconds(60))
+        {
+            auto refreshed = auth_->refreshTokens(*s->tokens.refresh_token);
+            if(refreshed.has_value())
+            {
+                sessions_.replaceTokens(sid, *refreshed);
+                s->tokens = std::move(*refreshed);
+            }
+            else
+            {
+                spdlog::info("Token refresh failed; dropping session: {}",
+                             mw::errorMsg(refreshed.error()));
+                sessions_.drop(sid);
+                return std::nullopt;
+            }
+        }
+    }
+    return s;
+}
+
+std::optional<std::string>
+AuthHandler::userIdForRequest(const httplib::Request& req) const
+{
+    auto sid = getCookie(req, cookie_name_);
+    if(sid.empty()) return std::nullopt;
+    return sessions_.peekUserId(sid);
+}
+
+std::optional<Session>
+AuthHandler::requireAuth(const httplib::Request& req,
+                         httplib::Response& res) const
+{
+    auto s = sessionForRequest(req);
+    if(s.has_value())
+    {
+        return s;
+    }
+    if(req.has_header("HX-Request"))
+    {
+        res.status = 401;
+        res.set_content("Authentication required", "text/plain");
+        return std::nullopt;
+    }
+    std::string return_to = req.path;
+    if(!req.params.empty())
+    {
+        return_to += "?" + paramsToQuery(req.params);
+    }
+    res.status = 302;
+    res.set_header("Location",
+                   "/oidc/login?return_to=" + mw::urlEncode(return_to));
+    return std::nullopt;
+}
+
+void AuthHandler::registerRoutes(httplib::Server& s)
+{
+    s.Get("/oidc/login", [this](const httplib::Request& req,
+                                httplib::Response& res)
+    {
+        const std::string return_to = parseQuery(req, "return_to");
+        const std::string state = SessionStore::randomToken(16);
+        sessions_.rememberPendingLogin(
+            state,
+            return_to.empty() ? std::string("/") : return_to);
+
+        std::string initial = auth_->initialURL();
+        // mw::AuthOpenIDConnect::initialURL() already includes
+        // ?client_id=... etc. Append our state.
+        const char sep = (initial.find('?') == std::string::npos)
+            ? '?' : '&';
+        initial += sep;
+        initial += "state=" + state;
+        res.status = 302;
+        res.set_header("Location", initial);
+    });
+
+    s.Get("/oidc/callback", [this](const httplib::Request& req,
+                                   httplib::Response& res)
+    {
+        const std::string state = parseQuery(req, "state");
+        const std::string code = parseQuery(req, "code");
+        if(state.empty() || code.empty())
+        {
+            res.status = 400;
+            res.set_content("Missing code or state", "text/plain");
+            return;
+        }
+        auto pending = sessions_.consumePendingLogin(state);
+        if(!pending.has_value())
+        {
+            res.status = 400;
+            res.set_content("Invalid or expired state", "text/plain");
+            return;
+        }
+        auto tokens_r = auth_->authenticate(code);
+        if(!tokens_r.has_value())
+        {
+            spdlog::warn("OIDC token exchange failed: {}",
+                         mw::errorMsg(tokens_r.error()));
+            res.status = 502;
+            res.set_content("Login failed", "text/plain");
+            return;
+        }
+        auto user_r = auth_->getUser(*tokens_r);
+        if(!user_r.has_value())
+        {
+            spdlog::warn("OIDC userinfo failed: {}",
+                         mw::errorMsg(user_r.error()));
+            res.status = 502;
+            res.set_content("Login failed", "text/plain");
+            return;
+        }
+        const std::string sid = sessions_.create(
+            std::move(*user_r), std::move(*tokens_r));
+        // Set both the opaque session cookie and a non-HttpOnly CSRF
+        // cookie. The CSRF cookie value mirrors the session's stored
+        // csrf_token; double-submit pattern.
+        auto sess = sessions_.get(sid);
+        if(!sess.has_value())
+        {
+            res.status = 500;
+            res.set_content("Failed to create session", "text/plain");
+            return;
+        }
+        res.set_header("Set-Cookie",
+                       buildSetCookie(cookie_name_, sid, max_age_, true));
+        res.set_header("Set-Cookie",
+                       std::format(
+                           "overseer_csrf={}; SameSite=Lax; Path=/; "
+                           "Max-Age={}; Secure",
+                           sess->csrf_token, max_age_.count() * 60));
+        res.status = 302;
+        res.set_header("Location", pending->return_to);
+    });
+
+    s.Post("/oidc/logout", [this](const httplib::Request& req,
+                                  httplib::Response& res)
+    {
+        auto sid = getCookie(req, cookie_name_);
+        if(!sid.empty())
+        {
+            sessions_.drop(sid);
+        }
+        res.set_header("Set-Cookie", clearCookie(cookie_name_));
+        res.set_header("Set-Cookie", clearCookie("overseer_csrf"));
+
+        // Fire-and-forget the IdP's RP-initiated logout. The local
+        // session is already gone; we don't surface IdP failures.
+        if(!end_session_endpoint_.empty())
+        {
+            std::string url = end_session_endpoint_;
+            std::thread([url]()
+            {
+                try
+                {
+                    mw::HTTPSession http;
+                    (void)http.transferTimeout(std::chrono::seconds(5));
+                    auto r = http.get(url);
+                    if(!r.has_value())
+                    {
+                        spdlog::warn("end_session GET failed: {}",
+                                     mw::errorMsg(r.error()));
+                    }
+                }
+                catch(const std::exception& e)
+                {
+                    spdlog::warn("end_session thread error: {}",
+                                 e.what());
+                }
+            }).detach();
+        }
+
+        res.status = 302;
+        res.set_header("Location", "/");
+    });
+}
+
+} // namespace overseer
diff --git a/src/overseer/auth_handler.hpp b/src/overseer/auth_handler.hpp
new file mode 100644
index 0000000..0bca0f5
--- /dev/null
+++ b/src/overseer/auth_handler.hpp
@@ -0,0 +1,52 @@
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include <httplib.h>
+#include <mw/auth.hpp>
+
+#include "config.hpp"
+#include "session.hpp"
+
+namespace overseer
+{
+
+class AuthHandler
+{
+public:
+    AuthHandler(const Config& config,
+                std::unique_ptr<mw::AuthInterface> auth,
+                SessionStore& sessions);
+
+    void registerRoutes(httplib::Server& s);
+
+    /// Pull the session cookie from req, look up the session, return
+    /// it. Returns nullopt if no/expired session.
+    std::optional<Session> sessionForRequest(const httplib::Request& req) const;
+
+    /// Non-mutating: returns the user id for the session cookie if
+    /// any. Does NOT trigger a token refresh, so it's safe to call on
+    /// every request from the access logger.
+    std::optional<std::string>
+    userIdForRequest(const httplib::Request& req) const;
+
+    /// Convenience: require an authenticated session. If absent,
+    /// fills res with a 302 to /oidc/login (or 401 for HX-Request),
+    /// and returns nullopt. Otherwise returns the session.
+    std::optional<Session> requireAuth(const httplib::Request& req,
+                                       httplib::Response& res) const;
+
+    const std::string& cookieName() const { return cookie_name_; }
+    SessionStore& sessions() { return sessions_; }
+
+private:
+    std::unique_ptr<mw::AuthInterface> auth_;
+    SessionStore& sessions_;
+    std::string cookie_name_;
+    std::chrono::minutes max_age_;
+    std::string issuer_url_;            // for discovery
+    std::string end_session_endpoint_;  // empty if IdP omits the field
+};
+
+} // namespace overseer
diff --git a/src/overseer/config.cpp b/src/overseer/config.cpp
new file mode 100644
index 0000000..6694e5c
--- /dev/null
+++ b/src/overseer/config.cpp
@@ -0,0 +1,139 @@
+#include "config.hpp"
+
+#include <filesystem>
+#include <format>
+#include <string>
+#include <sys/stat.h>
+
+#include <mw/error.hpp>
+#include <spdlog/spdlog.h>
+#include <yaml-cpp/yaml.h>
+
+namespace overseer
+{
+
+namespace
+{
+
+mw::E<void> checkFileMode(const std::filesystem::path& path)
+{
+    struct stat st;
+    if(::stat(path.c_str(), &st) != 0)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Cannot stat config file {}", path.string())));
+    }
+    // World-readable or world-writable bits set?
+    if((st.st_mode & 0007) != 0)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Config file {} is world-accessible (mode {:o}); "
+            "tighten permissions to 0640 or stricter.",
+            path.string(),
+            static_cast<unsigned>(st.st_mode & 0777))));
+    }
+    if((st.st_mode & 0040) == 0 && (st.st_mode & 0400) == 0)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Config file {} is not readable", path.string())));
+    }
+    return {};
+}
+
+} // namespace
+
+mw::E<Config> loadConfig(const std::filesystem::path& path)
+{
+    std::error_code ec;
+    if(!std::filesystem::exists(path, ec))
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Config file {} does not exist", path.string())));
+    }
+
+    if(auto rt = checkFileMode(path); !rt.has_value())
+    {
+        return std::unexpected(std::move(rt).error());
+    }
+
+    YAML::Node root;
+    try
+    {
+        root = YAML::LoadFile(path.string());
+    }
+    catch(const YAML::Exception& e)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "YAML parse error in {}: {}", path.string(), e.what())));
+    }
+
+    Config c;
+    if(root["bind_address"])
+    {
+        c.bind_address = root["bind_address"].as<std::string>();
+    }
+    if(root["port"])
+    {
+        c.port = root["port"].as<int>();
+    }
+    if(root["log_level"])
+    {
+        c.log_level = root["log_level"].as<std::string>();
+    }
+    if(root["db_path"])
+    {
+        c.db_path = root["db_path"].as<std::string>();
+    }
+
+    auto oidc = root["oidc"];
+    if(!oidc || !oidc.IsMap())
+    {
+        return std::unexpected(mw::runtimeError(
+            "Config missing required 'oidc' section"));
+    }
+    auto requireString = [&](YAML::Node n, const char* key,
+                             std::string& dest) -> mw::E<void>
+    {
+        auto v = n[key];
+        if(!v || !v.IsScalar() || v.as<std::string>().empty())
+        {
+            return std::unexpected(mw::runtimeError(std::format(
+                "Config missing required oidc.{}", key)));
+        }
+        dest = v.as<std::string>();
+        return {};
+    };
+    if(auto r = requireString(oidc, "issuer_url",    c.oidc.issuer_url);    !r) return std::unexpected(r.error());
+    if(auto r = requireString(oidc, "client_id",     c.oidc.client_id);     !r) return std::unexpected(r.error());
+    if(auto r = requireString(oidc, "client_secret", c.oidc.client_secret); !r) return std::unexpected(r.error());
+    if(auto r = requireString(oidc, "redirect_uri",  c.oidc.redirect_uri);  !r) return std::unexpected(r.error());
+
+    if(auto sess = root["session"]; sess && sess.IsMap())
+    {
+        if(sess["cookie_name"])
+        {
+            c.session.cookie_name = sess["cookie_name"].as<std::string>();
+        }
+        if(sess["max_age_minutes"])
+        {
+            c.session.max_age = std::chrono::minutes(
+                sess["max_age_minutes"].as<int>());
+        }
+    }
+
+    if(c.bind_address.empty())
+    {
+        return std::unexpected(mw::runtimeError(
+            "bind_address must be non-empty"));
+    }
+    const bool is_unix = c.bind_address.starts_with("unix:");
+    if(!is_unix && (c.port < 1 || c.port > 65535))
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Invalid port {}", c.port)));
+    }
+
+    return c;
+}
+
+} // namespace overseer
diff --git a/src/overseer/config.hpp b/src/overseer/config.hpp
new file mode 100644
index 0000000..9a339a9
--- /dev/null
+++ b/src/overseer/config.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <chrono>
+#include <filesystem>
+#include <string>
+
+#include <mw/error.hpp>
+
+namespace overseer
+{
+
+struct OIDCConfig
+{
+    std::string issuer_url;
+    std::string client_id;
+    std::string client_secret;
+    std::string redirect_uri;
+};
+
+struct SessionConfig
+{
+    std::string cookie_name = "overseer_sid";
+    std::chrono::minutes max_age{480};
+};
+
+struct Config
+{
+    std::string bind_address = "127.0.0.1";
+    int port = 8080;
+    std::string log_level = "info";
+    std::string db_path = "/var/lib/overseer/overseer.db";
+    OIDCConfig oidc;
+    SessionConfig session;
+};
+
+mw::E<Config> loadConfig(const std::filesystem::path& path);
+
+} // namespace overseer
diff --git a/src/overseer/main.cpp b/src/overseer/main.cpp
new file mode 100644
index 0000000..50291bf
--- /dev/null
+++ b/src/overseer/main.cpp
@@ -0,0 +1,165 @@
+#include <cstdlib>
+#include <filesystem>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include <Magick++.h>
+#include <cxxopts.hpp>
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+#include <spdlog/spdlog.h>
+#include <spdlog/sinks/systemd_sink.h>
+
+#include "config.hpp"
+#include "db/migrations.hpp"
+#include "modules/inventory/routes.hpp"
+#include "server.hpp"
+
+namespace
+{
+
+void applyStartupPragmas(mw::SQLite& db)
+{
+    // PRAGMA journal_mode and foreign_keys are already set by libmw's
+    // connectFile(). Add the remainder.
+    (void)db.execute("PRAGMA synchronous = NORMAL;");
+    (void)db.execute("PRAGMA busy_timeout = 5000;");
+    (void)db.execute("PRAGMA temp_store = MEMORY;");
+}
+
+void logImageCoders()
+{
+    // ImageMagick's coder list is the source of truth for which input
+    // formats this build can decode.
+    try
+    {
+        std::vector<Magick::CoderInfo> coders;
+        Magick::coderInfoList(&coders,
+                              Magick::CoderInfo::TrueMatch,
+                              Magick::CoderInfo::AnyMatch,
+                              Magick::CoderInfo::AnyMatch);
+        std::string available;
+        for(const auto& c : coders)
+        {
+            if(c.isReadable())
+            {
+                if(!available.empty()) available += ",";
+                available += c.name();
+            }
+        }
+        spdlog::warn("ImageMagick readable coders: {}", available);
+    }
+    catch(const std::exception& e)
+    {
+        spdlog::warn("Could not enumerate ImageMagick coders: {}", e.what());
+    }
+}
+
+void configureLogging()
+{
+    // When started under systemd, send logs to journald and drop the
+    // stderr sink. systemd's `JOURNAL_STREAM` is set on the inherited
+    // stderr; its presence is the documented signal that journald is
+    // attached.
+    if(std::getenv("JOURNAL_STREAM"))
+    {
+        auto journal = std::make_shared<
+            spdlog::sinks::systemd_sink_st>("overseer");
+        auto logger = std::make_shared<spdlog::logger>(
+            "overseer", journal);
+        logger->set_level(spdlog::default_logger()->level());
+        spdlog::set_default_logger(std::move(logger));
+    }
+    spdlog::set_pattern("[%l] [%t] %v");
+}
+
+} // namespace
+
+int main(int argc, char** argv)
+{
+    cxxopts::Options opts(
+        "overseer", "Overseer family information system");
+    opts.add_options()
+        ("c,config", "Path to YAML configuration",
+            cxxopts::value<std::string>()->default_value("/etc/overseer.yaml"))
+        ("static-dir", "Override static-files directory (dev)",
+            cxxopts::value<std::string>()->default_value(
+                OVERSEER_DEFAULT_STATIC_DIR))
+        ("template-dir", "Override template directory (dev)",
+            cxxopts::value<std::string>()->default_value(
+                OVERSEER_DEFAULT_TEMPLATE_DIR))
+        ("dev", "Enable dev mode (template hot reload, verbose logging)")
+        ("h,help", "Print help");
+
+    auto args = opts.parse(argc, argv);
+    if(args.count("help"))
+    {
+        spdlog::info("{}", opts.help());
+        return 0;
+    }
+
+    Magick::InitializeMagick(argv[0]);
+
+    auto cfg_r = overseer::loadConfig(args["config"].as<std::string>());
+    if(!cfg_r.has_value())
+    {
+        spdlog::error("Configuration error: {}",
+                      mw::errorMsg(cfg_r.error()));
+        return 2;
+    }
+    overseer::Config cfg = std::move(*cfg_r);
+
+    spdlog::set_level(spdlog::level::from_str(cfg.log_level));
+    if(args.count("dev")) spdlog::set_level(spdlog::level::debug);
+    configureLogging();
+
+    logImageCoders();
+
+    // Make sure the DB directory exists.
+    std::error_code ec;
+    auto dir = std::filesystem::path(cfg.db_path).parent_path();
+    if(!dir.empty())
+    {
+        std::filesystem::create_directories(dir, ec);
+    }
+
+    auto db_r = mw::SQLite::connectFile(cfg.db_path);
+    if(!db_r.has_value())
+    {
+        spdlog::error("Failed to open DB at {}: {}",
+                      cfg.db_path, mw::errorMsg(db_r.error()));
+        return 3;
+    }
+    auto& db = **db_r;
+    applyStartupPragmas(db);
+
+    if(auto rt = overseer::db::migrateIfNeeded(db, cfg.db_path);
+       !rt.has_value())
+    {
+        spdlog::error("Migration failed: {}", mw::errorMsg(rt.error()));
+        return 4;
+    }
+
+    overseer::Server server(
+        cfg, db,
+        args["static-dir"].as<std::string>(),
+        args["template-dir"].as<std::string>(),
+        args.count("dev") > 0);
+
+    std::vector<std::unique_ptr<overseer::Module>> mods;
+    mods.emplace_back(
+        overseer::modules::inventory_module::create());
+    server.setModules(std::move(mods));
+
+    if(auto rt = server.start(); !rt.has_value())
+    {
+        spdlog::error("Failed to start server: {}",
+                      mw::errorMsg(rt.error()));
+        return 5;
+    }
+    spdlog::info("Overseer listening on {}:{}",
+                 cfg.bind_address, cfg.port);
+    server.wait();
+    return 0;
+}
diff --git a/src/overseer/module.hpp b/src/overseer/module.hpp
new file mode 100644
index 0000000..e625745
--- /dev/null
+++ b/src/overseer/module.hpp
@@ -0,0 +1,26 @@
+#pragma once
+
+#include <string>
+
+#include <httplib.h>
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+namespace overseer
+{
+
+class Server;
+
+/// A module is a feature-area subordinate to the binary. v1 has only
+/// `InventoryModule`. Adding a future `DocModule` means writing a new
+/// subclass and registering it from main().
+class Module
+{
+public:
+    virtual ~Module() = default;
+    virtual std::string name() const = 0;
+    virtual void registerRoutes(httplib::Server& s, Server& app) = 0;
+    virtual mw::E<void> migrate(mw::SQLite&) { return {}; }
+};
+
+} // namespace overseer
diff --git a/src/overseer/modules/inventory/routes.cpp b/src/overseer/modules/inventory/routes.cpp
new file mode 100644
index 0000000..7562b48
--- /dev/null
+++ b/src/overseer/modules/inventory/routes.cpp
@@ -0,0 +1,883 @@
+#include "routes.hpp"
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <httplib.h>
+#include <mw/error.hpp>
+#include <mw/utils.hpp>
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include "inventory/repo.hpp"
+#include "inventory/storage.hpp"
+#include "inventory/stuff.hpp"
+#include "overseer/auth_handler.hpp"
+#include "overseer/render.hpp"
+#include "overseer/server.hpp"
+#include "overseer/session.hpp"
+#include "views.hpp"
+
+namespace overseer::modules::inventory_module
+{
+
+namespace
+{
+
+namespace inv = ::overseer::inventory;
+using ::overseer::Session;
+
+// ---- small helpers ---------------------------------------------------
+
+bool isHxRequest(const httplib::Request& req)
+{
+    return req.has_header("HX-Request");
+}
+
+std::optional<int64_t> pathInt(const httplib::Request& req, size_t i)
+{
+    try
+    {
+        return std::stoll(req.matches[i].str());
+    }
+    catch(...)
+    {
+        return std::nullopt;
+    }
+}
+
+std::optional<int64_t> formInt(const httplib::Request& req,
+                               const std::string& name)
+{
+    if(!req.has_param(name)) return std::nullopt;
+    try
+    {
+        return std::stoll(req.get_param_value(name));
+    }
+    catch(...)
+    {
+        return std::nullopt;
+    }
+}
+
+std::optional<std::string> formOptStr(const httplib::Request& req,
+                                      const std::string& name)
+{
+    if(!req.has_param(name)) return std::nullopt;
+    auto s = req.get_param_value(name);
+    if(s.empty()) return std::nullopt;
+    return s;
+}
+
+// Render a template into res. On template failure, return 500.
+void respondHtml(const Session* sess,
+                 ::overseer::Server& app,
+                 const std::string& tmpl,
+                 nlohmann::json data,
+                 httplib::Response& res)
+{
+    if(sess)
+    {
+        data["csrf_token"] = sess->csrf_token;
+        data["user_name"] = sess->user.name;
+    }
+    auto out = app.render().render("inventory", tmpl, data);
+    if(!out.has_value())
+    {
+        spdlog::error("Template render failed: {}",
+                      mw::errorMsg(out.error()));
+        res.status = 500;
+        res.set_content("Template error", "text/plain");
+        return;
+    }
+    res.status = 200;
+    res.set_content(*out, "text/html; charset=utf-8");
+}
+
+// Map our typed errors (NOT_FOUND/NOT_EMPTY/NAME_TAKEN/BAD_INPUT) to
+// HTTP statuses. Anything else is a 500.
+int errorToStatus(const mw::Error& e)
+{
+    const std::string& msg = mw::errorMsg(e);
+    if(msg == inv::ERR_NOT_FOUND)  return 404;
+    if(msg == inv::ERR_NOT_EMPTY)  return 409;
+    if(msg == inv::ERR_NAME_TAKEN) return 409;
+    if(msg == inv::ERR_BAD_INPUT)  return 400;
+    return 500;
+}
+
+const char* statusText(int s)
+{
+    switch(s)
+    {
+    case 400: return "Bad request";
+    case 403: return "Forbidden";
+    case 404: return "Not found";
+    case 409: return "Conflict";
+    case 415: return "Unsupported media type";
+    case 500: return "Internal server error";
+    default:  return "Error";
+    }
+}
+
+// Friendly message for known sentinels; the raw error string otherwise.
+std::string friendlyMessage(const mw::Error& e)
+{
+    const std::string& msg = mw::errorMsg(e);
+    if(msg == inv::ERR_NOT_FOUND)  return "The requested item does not exist.";
+    if(msg == inv::ERR_NOT_EMPTY)
+        return "This storage still contains sub-storages or stuff. "
+               "Move or delete them first.";
+    if(msg == inv::ERR_NAME_TAKEN)
+        return "A sibling with this name already exists.";
+    if(msg == inv::ERR_BAD_INPUT) return "The submitted data is invalid.";
+    return msg;
+}
+
+void respondErrorPage(const Session* sess,
+                      ::overseer::Server& app,
+                      int status,
+                      const std::string& message,
+                      const httplib::Request& req,
+                      httplib::Response& res)
+{
+    res.status = status;
+    // htmx fragments and non-HTML clients get plain text; full-page
+    // navigations get the styled error template.
+    const auto accept = req.get_header_value("Accept");
+    const bool wants_html =
+        accept.find("text/html") != std::string::npos
+        && !req.has_header("HX-Request");
+    if(!wants_html)
+    {
+        res.set_content(message.empty() ? statusText(status) : message,
+                        "text/plain");
+        return;
+    }
+    nlohmann::json data;
+    data["status"]      = status;
+    data["status_text"] = statusText(status);
+    data["message"]     = message;
+    if(sess)
+    {
+        data["csrf_token"] = sess->csrf_token;
+        data["user_name"]  = sess->user.name;
+    }
+    auto out = app.render().render("inventory", "error.html.inja", data);
+    if(out.has_value())
+    {
+        res.set_content(*out, "text/html; charset=utf-8");
+    }
+    else
+    {
+        res.set_content(message.empty() ? statusText(status) : message,
+                        "text/plain");
+    }
+}
+
+void respondError(const mw::Error& e,
+                  ::overseer::Server& app,
+                  const Session* sess,
+                  const httplib::Request& req,
+                  httplib::Response& res)
+{
+    respondErrorPage(sess, app, errorToStatus(e),
+                     friendlyMessage(e), req, res);
+}
+
+// CSRF check for mutating requests. Compares the form field
+// `csrf_token` or header `X-CSRF-Token` to the session's token.
+// Multipart uploads put their text parts in `req.files`, so we check
+// that map too.
+bool checkCsrf(const httplib::Request& req, const Session& sess)
+{
+    std::string supplied;
+    if(auto h = req.get_header_value("X-CSRF-Token"); !h.empty())
+    {
+        supplied = h;
+    }
+    else if(req.has_param("csrf_token"))
+    {
+        supplied = req.get_param_value("csrf_token");
+    }
+    else if(req.has_file("csrf_token"))
+    {
+        supplied = req.get_file_value("csrf_token").content;
+    }
+    return !supplied.empty() && supplied == sess.csrf_token;
+}
+
+// Defense in depth: confirm the request originated from our own page
+// by comparing the Origin (or, if absent, Referer) header against the
+// configured redirect_uri host. If the server hasn't been able to
+// derive an expected origin we skip the check rather than locking
+// users out.
+bool checkOrigin(const httplib::Request& req,
+                 const std::string& expected)
+{
+    if(expected.empty()) return true;
+    auto origin = req.get_header_value("Origin");
+    if(!origin.empty())
+    {
+        return origin == expected;
+    }
+    auto referer = req.get_header_value("Referer");
+    if(!referer.empty())
+    {
+        return referer.rfind(expected, 0) == 0;   // starts_with
+    }
+    // No Origin and no Referer: a same-origin htmx request *should*
+    // include at least one. Reject.
+    return false;
+}
+
+// Design §7.4: a mutating request passes if EITHER
+//   - it carries HX-Request and is same-origin, OR
+//   - it carries a matching double-submit CSRF token.
+bool passesCsrf(const httplib::Request& req,
+                const Session& sess,
+                const std::string& expected)
+{
+    const bool hx = req.has_header("HX-Request");
+    if(hx && checkOrigin(req, expected)) return true;
+    return checkCsrf(req, sess);
+}
+
+// ---- handlers --------------------------------------------------------
+
+void handleIndex(::overseer::Server& app,
+                 const httplib::Request& req,
+                 httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+
+    auto roots_r = app.repo().listChildren(std::nullopt);
+    if(!roots_r.has_value())
+    {
+        respondError(roots_r.error(), app, &*sess, req, res);
+        return;
+    }
+
+    nlohmann::json data;
+    data["roots"] = storagesToJson(*roots_r);
+    respondHtml(&*sess, app, "storage_list.html.inja", std::move(data), res);
+}
+
+void handleStorageShow(::overseer::Server& app,
+                       const httplib::Request& req,
+                       httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    auto s_r = app.repo().getStorage(*id);
+    if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+    auto children_r = app.repo().listChildren(*id);
+    if(!children_r.has_value())
+    { respondError(children_r.error(), app, &*sess, req, res); return; }
+    auto stuffs_r = app.repo().listInStorage(*id);
+    if(!stuffs_r.has_value())
+    { respondError(stuffs_r.error(), app, &*sess, req, res); return; }
+    auto path_r = app.repo().pathTo(*id);
+    if(!path_r.has_value()) { respondError(path_r.error(), app, &*sess, req, res); return; }
+
+    nlohmann::json data;
+    data["storage"]  = toJson(*s_r);
+    data["children"] = storagesToJson(*children_r);
+    data["stuffs"]   = stuffsToJson(*stuffs_r);
+    data["path"]     = storagesToJson(*path_r);
+    respondHtml(&*sess, app, "storage_show.html.inja", std::move(data), res);
+}
+
+void handleStorageNew(::overseer::Server& app,
+                      const httplib::Request& req,
+                      httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    nlohmann::json data;
+    data["mode"] = "create";
+    if(auto pid = formInt(req, "parent_id"); pid.has_value())
+    {
+        data["parent_id"] = *pid;
+    }
+    respondHtml(&*sess, app, "storage_form.html.inja", std::move(data), res);
+}
+
+void handleStorageEdit(::overseer::Server& app,
+                       const httplib::Request& req,
+                       httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+    auto s_r = app.repo().getStorage(*id);
+    if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+
+    nlohmann::json data;
+    data["mode"] = "edit";
+    data["storage"] = toJson(*s_r);
+    respondHtml(&*sess, app, "storage_form.html.inja", std::move(data), res);
+}
+
+void handleStorageCreate(::overseer::Server& app,
+                         const httplib::Request& req,
+                         httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    {
+        respondErrorPage(&*sess, app, 403, "Forbidden", req, res);
+        return;
+    }
+    inv::Storage s;
+    if(auto v = formOptStr(req, "name"); v.has_value()) s.name = *v;
+    s.description = formOptStr(req, "description");
+    if(auto pid = formInt(req, "parent_id"); pid.has_value())
+    {
+        s.parent_id = *pid;
+    }
+    auto id_r = app.repo().createStorage(s);
+    if(!id_r.has_value()) { respondError(id_r.error(), app, &*sess, req, res); return; }
+    res.status = 303;
+    res.set_header("Location",
+                   "/inventory/storage/" + std::to_string(*id_r));
+}
+
+void handleStorageUpdate(::overseer::Server& app,
+                         const httplib::Request& req,
+                         httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    auto cur_r = app.repo().getStorage(*id);
+    if(!cur_r.has_value()) { respondError(cur_r.error(), app, &*sess, req, res); return; }
+    inv::Storage s = *cur_r;
+    if(auto v = formOptStr(req, "name"); v.has_value()) s.name = *v;
+    s.description = formOptStr(req, "description");
+    if(auto pid = formInt(req, "parent_id"); pid.has_value())
+    {
+        s.parent_id = *pid;
+    }
+    else if(req.has_param("parent_id"))
+    {
+        // Empty value -> root
+        s.parent_id = std::nullopt;
+    }
+    if(auto r = app.repo().updateStorage(s); !r.has_value())
+    { respondError(r.error(), app, &*sess, req, res); return; }
+    res.status = 303;
+    res.set_header("Location",
+                   "/inventory/storage/" + std::to_string(*id));
+}
+
+void handleStorageDelete(::overseer::Server& app,
+                         const httplib::Request& req,
+                         httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    if(auto r = app.repo().deleteStorage(*id); !r.has_value())
+    { respondError(r.error(), app, &*sess, req, res); return; }
+
+    if(isHxRequest(req))
+    {
+        // htmx swap: empty fragment
+        res.status = 200;
+        res.set_content("", "text/html");
+    }
+    else
+    {
+        res.status = 303;
+        res.set_header("Location", "/inventory");
+    }
+}
+
+void handleStorageChildren(::overseer::Server& app,
+                           const httplib::Request& req,
+                           httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    auto kids_r = app.repo().listChildren(*id);
+    if(!kids_r.has_value()) { respondError(kids_r.error(), app, &*sess, req, res); return; }
+    auto stuffs_r = app.repo().listInStorage(*id);
+    if(!stuffs_r.has_value()) { respondError(stuffs_r.error(), app, &*sess, req, res); return; }
+
+    nlohmann::json data;
+    data["children"] = storagesToJson(*kids_r);
+    data["stuffs"]   = stuffsToJson(*stuffs_r);
+    respondHtml(&*sess, app, "fragment_tree_node.html.inja",
+                std::move(data), res);
+}
+
+// ---- stuff handlers --------------------------------------------------
+
+void handleStuffShow(::overseer::Server& app,
+                     const httplib::Request& req,
+                     httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+    auto s_r = app.repo().getStuff(*id);
+    if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+    auto cur_storage_r = app.repo().getStorage(s_r->storage_id);
+    auto moves_r = app.repo().recentMoves(*id);
+    if(!moves_r.has_value()) { respondError(moves_r.error(), app, &*sess, req, res); return; }
+
+    nlohmann::json data;
+    data["stuff"] = toJson(*s_r);
+    if(cur_storage_r.has_value())
+    {
+        data["current_storage"] = toJson(*cur_storage_r);
+    }
+    nlohmann::json moves = nlohmann::json::array();
+    for(const auto& m : *moves_r)
+    {
+        nlohmann::json mj;
+        mj["storage_id"] = m.storage_id;
+        mj["moved_at"]   = mw::timeToISO8601(m.moved_at);
+        auto st_r = app.repo().getStorage(m.storage_id);
+        if(st_r.has_value())
+        {
+            mj["storage_name"] = st_r->name;
+        }
+        moves.push_back(mj);
+    }
+    data["moves"] = moves;
+    respondHtml(&*sess, app, "stuff_show.html.inja",
+                std::move(data), res);
+}
+
+void handleStuffNew(::overseer::Server& app,
+                    const httplib::Request& req,
+                    httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    nlohmann::json data;
+    data["mode"] = "create";
+    if(auto sid = formInt(req, "storage_id"); sid.has_value())
+    {
+        data["storage_id"] = *sid;
+    }
+    respondHtml(&*sess, app, "stuff_form.html.inja",
+                std::move(data), res);
+}
+
+void handleStuffEdit(::overseer::Server& app,
+                     const httplib::Request& req,
+                     httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+    auto s_r = app.repo().getStuff(*id);
+    if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+    nlohmann::json data;
+    data["mode"] = "edit";
+    data["stuff"] = toJson(*s_r);
+    respondHtml(&*sess, app, "stuff_form.html.inja",
+                std::move(data), res);
+}
+
+void handleStuffCreate(::overseer::Server& app,
+                       const httplib::Request& req,
+                       httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    inv::Stuff s;
+    if(auto v = formOptStr(req, "name"); v.has_value()) s.name = *v;
+    s.description = formOptStr(req, "description");
+    if(auto sid = formInt(req, "storage_id"); sid.has_value())
+    {
+        s.storage_id = *sid;
+    }
+    auto id_r = app.repo().createStuff(s);
+    if(!id_r.has_value()) { respondError(id_r.error(), app, &*sess, req, res); return; }
+    res.status = 303;
+    res.set_header("Location", "/inventory/stuff/" + std::to_string(*id_r));
+}
+
+void handleStuffUpdate(::overseer::Server& app,
+                       const httplib::Request& req,
+                       httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    auto cur_r = app.repo().getStuff(*id);
+    if(!cur_r.has_value()) { respondError(cur_r.error(), app, &*sess, req, res); return; }
+    inv::Stuff s = *cur_r;
+    if(auto v = formOptStr(req, "name"); v.has_value()) s.name = *v;
+    s.description = formOptStr(req, "description");
+    if(auto sid = formInt(req, "storage_id"); sid.has_value())
+    {
+        s.storage_id = *sid;
+    }
+    if(auto r = app.repo().updateStuff(s); !r.has_value())
+    { respondError(r.error(), app, &*sess, req, res); return; }
+    res.status = 303;
+    res.set_header("Location", "/inventory/stuff/" + std::to_string(*id));
+}
+
+void handleStuffDelete(::overseer::Server& app,
+                       const httplib::Request& req,
+                       httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+    if(auto r = app.repo().deleteStuff(*id); !r.has_value())
+    { respondError(r.error(), app, &*sess, req, res); return; }
+    if(isHxRequest(req))
+    {
+        res.status = 200;
+        res.set_content("", "text/html");
+    }
+    else
+    {
+        res.status = 303;
+        res.set_header("Location", "/inventory");
+    }
+}
+
+// GET /inventory/stuff/{id}/name → returns the current name span
+// fragment. Used by the Cancel button on the inline edit form.
+void handleStuffNameView(::overseer::Server& app,
+                         const httplib::Request& req,
+                         httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+    auto s_r = app.repo().getStuff(*id);
+    if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+    nlohmann::json data;
+    data["stuff"] = toJson(*s_r);
+    respondHtml(&*sess, app, "fragment_stuff_name.html.inja",
+                std::move(data), res);
+}
+
+// GET /inventory/stuff/{id}/name/edit → returns the edit form fragment.
+void handleStuffNameEdit(::overseer::Server& app,
+                         const httplib::Request& req,
+                         httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+    auto s_r = app.repo().getStuff(*id);
+    if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+    nlohmann::json data;
+    data["stuff"] = toJson(*s_r);
+    respondHtml(&*sess, app, "fragment_stuff_name_edit.html.inja",
+                std::move(data), res);
+}
+
+// PUT /inventory/stuff/{id}/name — body: form-encoded `name=...`.
+// On HX-Request, returns the name span fragment; otherwise 302 back
+// to the stuff page.
+void handleStuffNameUpdate(::overseer::Server& app,
+                           const httplib::Request& req,
+                           httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    auto name = formOptStr(req, "name");
+    if(!name.has_value())
+    { respondErrorPage(&*sess, app, 400, "Name is required", req, res); return; }
+
+    auto cur_r = app.repo().getStuff(*id);
+    if(!cur_r.has_value()) { respondError(cur_r.error(), app, &*sess, req, res); return; }
+    inv::Stuff s = *cur_r;
+    s.name = *name;
+    if(auto r = app.repo().updateStuff(s); !r.has_value())
+    { respondError(r.error(), app, &*sess, req, res); return; }
+
+    if(isHxRequest(req))
+    {
+        nlohmann::json data;
+        data["stuff"] = toJson(s);
+        respondHtml(&*sess, app, "fragment_stuff_name.html.inja",
+                    std::move(data), res);
+        return;
+    }
+    res.status = 302;
+    res.set_header("Location", "/inventory/stuff/" + std::to_string(*id));
+}
+
+void handleStuffMove(::overseer::Server& app,
+                     const httplib::Request& req,
+                     httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+    auto id = pathInt(req, 1);
+    auto sid = formInt(req, "storage_id");
+    if(!id.has_value() || !sid.has_value())
+    { respondErrorPage(&*sess, app, 400, "Bad request", req, res); return; }
+    if(auto r = app.repo().moveStuff(*id, *sid); !r.has_value())
+    { respondError(r.error(), app, &*sess, req, res); return; }
+
+    if(isHxRequest(req))
+    {
+        auto st_r = app.repo().getStorage(*sid);
+        if(!st_r.has_value())
+        { respondError(st_r.error(), app, &*sess, req, res); return; }
+        nlohmann::json data;
+        data["storage"] = toJson(*st_r);
+        respondHtml(&*sess, app, "fragment_move_status.html.inja",
+                    std::move(data), res);
+        return;
+    }
+    res.status = 303;
+    res.set_header("Location", "/inventory/stuff/" + std::to_string(*id));
+}
+
+// ---- photo upload ----------------------------------------------------
+
+void handlePhotoUpload(::overseer::Server& app,
+                       const httplib::Request& req,
+                       httplib::Response& res,
+                       bool is_storage)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    if(!passesCsrf(req, *sess, app.expectedOrigin()))
+    { respondErrorPage(&*sess, app, 403, "Forbidden", req, res); return; }
+
+    auto id = pathInt(req, 1);
+    if(!id.has_value())
+    { respondErrorPage(&*sess, app, 404, "", req, res); return; }
+
+    if(!req.has_file("photo"))
+    {
+        respondErrorPage(&*sess, app, 400,
+                         "Missing 'photo' field", req, res);
+        return;
+    }
+    const auto& file = req.get_file_value("photo");
+    static const std::vector<std::string> allowed = {
+        "image/jpeg","image/png","image/webp","image/heic",
+        "image/avif","image/gif"
+    };
+    bool ok = false;
+    for(const auto& m : allowed)
+    {
+        if(file.content_type == m) { ok = true; break; }
+    }
+    if(!ok)
+    {
+        respondErrorPage(&*sess, app, 415,
+                         "Unsupported image type", req, res);
+        return;
+    }
+
+    std::span<const unsigned char> bytes(
+        reinterpret_cast<const unsigned char*>(file.content.data()),
+        file.content.size());
+    auto att_r = app.repo().putAttachment(bytes, file.content_type);
+    if(!att_r.has_value()) { respondError(att_r.error(), app, &*sess, req, res); return; }
+
+    if(is_storage)
+    {
+        auto s_r = app.repo().getStorage(*id);
+        if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+        inv::Storage s = *s_r;
+        s.attachment_id = *att_r;
+        if(auto r = app.repo().updateStorage(s); !r.has_value())
+        { respondError(r.error(), app, &*sess, req, res); return; }
+        res.status = 303;
+        res.set_header("Location",
+                       "/inventory/storage/" + std::to_string(*id));
+    }
+    else
+    {
+        auto s_r = app.repo().getStuff(*id);
+        if(!s_r.has_value()) { respondError(s_r.error(), app, &*sess, req, res); return; }
+        inv::Stuff s = *s_r;
+        s.attachment_id = *att_r;
+        if(auto r = app.repo().updateStuff(s); !r.has_value())
+        { respondError(r.error(), app, &*sess, req, res); return; }
+        res.status = 303;
+        res.set_header("Location",
+                       "/inventory/stuff/" + std::to_string(*id));
+    }
+}
+
+// ---- search ----------------------------------------------------------
+
+void handleSearch(::overseer::Server& app,
+                  const httplib::Request& req,
+                  httplib::Response& res)
+{
+    auto sess = app.auth().requireAuth(req, res);
+    if(!sess.has_value()) return;
+    const std::string q = req.has_param("q")
+        ? req.get_param_value("q") : "";
+
+    nlohmann::json data;
+    data["q"] = q;
+    if(!q.empty())
+    {
+        auto stor_r = app.repo().searchStorages(q);
+        auto stuff_r = app.repo().searchStuffs(q);
+        if(stor_r.has_value())
+        {
+            data["storages"] = storagesToJson(*stor_r);
+        }
+        else
+        {
+            data["storages"] = nlohmann::json::array();
+        }
+        if(stuff_r.has_value())
+        {
+            data["stuffs"] = stuffsToJson(*stuff_r);
+        }
+        else
+        {
+            data["stuffs"] = nlohmann::json::array();
+        }
+    }
+    else
+    {
+        data["storages"] = nlohmann::json::array();
+        data["stuffs"] = nlohmann::json::array();
+    }
+
+    const std::string tmpl = isHxRequest(req)
+        ? "search_results.html.inja"
+        : "search_page.html.inja";
+    respondHtml(&*sess, app, tmpl, std::move(data), res);
+}
+
+} // namespace
+
+void InventoryModule::registerRoutes(httplib::Server& s,
+                                     ::overseer::Server& app)
+{
+    s.Get("/inventory",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleIndex(app, req, res); });
+    s.Get("/inventory/search",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleSearch(app, req, res); });
+
+    s.Get("/inventory/storage/new",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageNew(app, req, res); });
+    s.Post("/inventory/storage",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageCreate(app, req, res); });
+    s.Get(R"(/inventory/storage/(\d+))",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageShow(app, req, res); });
+    s.Get(R"(/inventory/storage/(\d+)/edit)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageEdit(app, req, res); });
+    s.Put(R"(/inventory/storage/(\d+))",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageUpdate(app, req, res); });
+    s.Delete(R"(/inventory/storage/(\d+))",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageDelete(app, req, res); });
+    s.Get(R"(/inventory/storage/(\d+)/children)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStorageChildren(app, req, res); });
+    s.Post(R"(/inventory/storage/(\d+)/photo)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handlePhotoUpload(app, req, res, /*is_storage=*/true); });
+
+    s.Get("/inventory/stuff/new",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffNew(app, req, res); });
+    s.Post("/inventory/stuff",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffCreate(app, req, res); });
+    s.Get(R"(/inventory/stuff/(\d+))",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffShow(app, req, res); });
+    s.Get(R"(/inventory/stuff/(\d+)/edit)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffEdit(app, req, res); });
+    s.Put(R"(/inventory/stuff/(\d+))",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffUpdate(app, req, res); });
+    s.Delete(R"(/inventory/stuff/(\d+))",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffDelete(app, req, res); });
+    s.Post(R"(/inventory/stuff/(\d+)/move)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffMove(app, req, res); });
+    s.Get(R"(/inventory/stuff/(\d+)/name)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffNameView(app, req, res); });
+    s.Get(R"(/inventory/stuff/(\d+)/name/edit)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffNameEdit(app, req, res); });
+    s.Put(R"(/inventory/stuff/(\d+)/name)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handleStuffNameUpdate(app, req, res); });
+    s.Post(R"(/inventory/stuff/(\d+)/photo)",
+        [&app](const httplib::Request& req, httplib::Response& res)
+        { handlePhotoUpload(app, req, res, /*is_storage=*/false); });
+}
+
+} // namespace overseer::modules::inventory_module
diff --git a/src/overseer/modules/inventory/routes.hpp b/src/overseer/modules/inventory/routes.hpp
new file mode 100644
index 0000000..ae68e49
--- /dev/null
+++ b/src/overseer/modules/inventory/routes.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <memory>
+
+#include "overseer/module.hpp"
+
+namespace overseer::modules::inventory_module
+{
+
+class InventoryModule : public ::overseer::Module
+{
+public:
+    std::string name() const override { return "inventory"; }
+    void registerRoutes(httplib::Server& s, ::overseer::Server& app) override;
+};
+
+inline std::unique_ptr<::overseer::Module> create()
+{
+    return std::make_unique<InventoryModule>();
+}
+
+} // namespace overseer::modules::inventory_module
diff --git a/src/overseer/modules/inventory/templates/_breadcrumbs.html.inja b/src/overseer/modules/inventory/templates/_breadcrumbs.html.inja
new file mode 100644
index 0000000..0c922c2
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/_breadcrumbs.html.inja
@@ -0,0 +1,11 @@
+<nav class="breadcrumbs">
+    <a href="{{ url_for("index") }}">All</a>
+    {% for crumb in path %}
+    <span class="sep">›</span>
+    {% if loop.is_last %}
+    <span class="here">{{ crumb.name }}</span>
+    {% else %}
+    <a href="{{ url_for("storage", crumb.id) }}">{{ crumb.name }}</a>
+    {% endif %}
+    {% endfor %}
+</nav>
diff --git a/src/overseer/modules/inventory/templates/_head.html.inja b/src/overseer/modules/inventory/templates/_head.html.inja
new file mode 100644
index 0000000..3748440
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/_head.html.inja
@@ -0,0 +1,16 @@
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="stylesheet" href="/static/css/overseer.css">
+<script src="/static/js/htmx.min.js" defer></script>
+{% if exists("csrf_token") %}
+<meta name="csrf-token" content="{{ csrf_token }}">
+<script>
+document.addEventListener("DOMContentLoaded", () => {
+    const m = document.querySelector('meta[name="csrf-token"]');
+    if(!m) return;
+    document.body.addEventListener("htmx:configRequest", (e) => {
+        e.detail.headers["X-CSRF-Token"] = m.content;
+    });
+});
+</script>
+{% endif %}
diff --git a/src/overseer/modules/inventory/templates/_topbar.html.inja b/src/overseer/modules/inventory/templates/_topbar.html.inja
new file mode 100644
index 0000000..0d77b45
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/_topbar.html.inja
@@ -0,0 +1,20 @@
+<header class="topbar">
+    <div class="brand"><a href="{{ url_for("index") }}">Overseer</a></div>
+    <form class="search" action="{{ url_for("search") }}" method="get">
+        <input type="search" name="q"
+               hx-get="{{ url_for("search") }}"
+               hx-trigger="input changed delay:250ms, search"
+               hx-target="#search-results"
+               hx-push-url="true"
+               placeholder="Search stuff and storages…">
+    </form>
+    <div class="user">
+        {% if exists("user_name") %}
+        <span class="user-name">{{ user_name }}</span>
+        <form action="{{ url_for("logout") }}" method="post" class="logout">
+            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+            <button type="submit">Sign out</button>
+        </form>
+        {% endif %}
+    </div>
+</header>
diff --git a/src/overseer/modules/inventory/templates/error.html.inja b/src/overseer/modules/inventory/templates/error.html.inja
new file mode 100644
index 0000000..c6d7e2f
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/error.html.inja
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>Error {{ status }} · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+<h1>{{ status }} {{ status_text }}</h1>
+{% if message %}
+<p class="error-message">{{ message }}</p>
+{% endif %}
+<p><a href="/inventory">Back to inventory</a></p>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/templates/fragment_move_status.html.inja b/src/overseer/modules/inventory/templates/fragment_move_status.html.inja
new file mode 100644
index 0000000..1e90df5
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/fragment_move_status.html.inja
@@ -0,0 +1,4 @@
+<span class="move-status">
+    Moved to
+    <a href="{{ url_for("storage", storage.id) }}">{{ storage.name }}</a>.
+</span>
diff --git a/src/overseer/modules/inventory/templates/fragment_stuff_name.html.inja b/src/overseer/modules/inventory/templates/fragment_stuff_name.html.inja
new file mode 100644
index 0000000..fde188c
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/fragment_stuff_name.html.inja
@@ -0,0 +1,5 @@
+<h1 class="stuff-name editable"
+    hx-get="{{ url_for("stuff_name_edit", stuff.id) }}"
+    hx-trigger="click"
+    hx-swap="outerHTML"
+    title="Click to edit">{{ stuff.name }}</h1>
diff --git a/src/overseer/modules/inventory/templates/fragment_stuff_name_edit.html.inja b/src/overseer/modules/inventory/templates/fragment_stuff_name_edit.html.inja
new file mode 100644
index 0000000..9d12acc
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/fragment_stuff_name_edit.html.inja
@@ -0,0 +1,11 @@
+<form class="stuff-name-edit"
+      hx-put="{{ url_for("stuff_name", stuff.id) }}"
+      hx-swap="outerHTML">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+    <input type="text" name="name" value="{{ stuff.name }}"
+           autofocus required>
+    <button type="submit">Save</button>
+    <button type="button"
+            hx-get="{{ url_for("stuff_name_view", stuff.id) }}"
+            hx-swap="outerHTML">Cancel</button>
+</form>
diff --git a/src/overseer/modules/inventory/templates/fragment_tree_node.html.inja b/src/overseer/modules/inventory/templates/fragment_tree_node.html.inja
new file mode 100644
index 0000000..35b18a7
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/fragment_tree_node.html.inja
@@ -0,0 +1,18 @@
+<ul class="tree-children">
+    {% for s in children %}
+    <li>
+        <details>
+            <summary><a href="{{ url_for("storage", s.id) }}">{{ s.name }}</a></summary>
+            <div class="children"
+                 hx-get="{{ url_for("storage_children", s.id) }}"
+                 hx-trigger="toggle once from:closest details"
+                 hx-swap="innerHTML"></div>
+        </details>
+    </li>
+    {% endfor %}
+    {% for s in stuffs %}
+    <li class="leaf">
+        <a href="{{ url_for("stuff", s.id) }}">📦 {{ s.name }}</a>
+    </li>
+    {% endfor %}
+</ul>
diff --git a/src/overseer/modules/inventory/templates/search_page.html.inja b/src/overseer/modules/inventory/templates/search_page.html.inja
new file mode 100644
index 0000000..e410012
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/search_page.html.inja
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>Search · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+<h1>Search results for "{{ q }}"</h1>
+<div id="search-results">
+    {% include "search_results.html.inja" %}
+</div>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/templates/search_results.html.inja b/src/overseer/modules/inventory/templates/search_results.html.inja
new file mode 100644
index 0000000..fb8f285
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/search_results.html.inja
@@ -0,0 +1,21 @@
+<div class="search-results">
+    {% if length(storages) > 0 %}
+    <h3>Storages</h3>
+    <ul>
+        {% for s in storages %}
+        <li><a href="{{ url_for("storage", s.id) }}">{{ s.name }}</a></li>
+        {% endfor %}
+    </ul>
+    {% endif %}
+    {% if length(stuffs) > 0 %}
+    <h3>Stuff</h3>
+    <ul>
+        {% for s in stuffs %}
+        <li><a href="{{ url_for("stuff", s.id) }}">{{ s.name }}</a></li>
+        {% endfor %}
+    </ul>
+    {% endif %}
+    {% if length(storages) == 0 and length(stuffs) == 0 %}
+    <p class="empty">No matches.</p>
+    {% endif %}
+</div>
diff --git a/src/overseer/modules/inventory/templates/storage_form.html.inja b/src/overseer/modules/inventory/templates/storage_form.html.inja
new file mode 100644
index 0000000..ecef295
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/storage_form.html.inja
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>Storage · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+<h1>{% if mode == "edit" %}Edit storage{% else %}New storage{% endif %}</h1>
+
+{% if mode == "edit" %}
+<form method="post"
+      action="{{ url_for("storage", storage.id) }}"
+      hx-put="{{ url_for("storage", storage.id) }}"
+      hx-target="body">
+{% else %}
+<form method="post" action="{{ url_for("storage_create") }}">
+{% endif %}
+    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+    {% if mode != "edit" and exists("parent_id") %}
+    <input type="hidden" name="parent_id" value="{{ parent_id }}">
+    {% endif %}
+    <label>Name
+        <input type="text" name="name" required
+            value="{% if mode == "edit" %}{{ storage.name }}{% endif %}">
+    </label>
+    <label>Description
+        <textarea name="description" rows="4">{% if mode == "edit" %}{{ storage.description }}{% endif %}</textarea>
+    </label>
+    <button type="submit">Save</button>
+</form>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/templates/storage_list.html.inja b/src/overseer/modules/inventory/templates/storage_list.html.inja
new file mode 100644
index 0000000..f471b0b
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/storage_list.html.inja
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>Inventory · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+<h1>Inventory</h1>
+<p class="actions">
+    <a class="btn" href="{{ url_for("storage_new") }}">+ New storage</a>
+</p>
+<ul class="tree">
+    {% for s in roots %}
+    <li>
+        <details>
+            <summary>
+                <a href="{{ url_for("storage", s.id) }}">{{ s.name }}</a>
+            </summary>
+            <div class="children"
+                 hx-get="{{ url_for("storage_children", s.id) }}"
+                 hx-trigger="toggle once from:closest details"
+                 hx-swap="innerHTML"></div>
+        </details>
+    </li>
+    {% endfor %}
+</ul>
+<div id="search-results"></div>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/templates/storage_show.html.inja b/src/overseer/modules/inventory/templates/storage_show.html.inja
new file mode 100644
index 0000000..dde8ff5
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/storage_show.html.inja
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>{{ storage.name }} · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+{% include "_breadcrumbs.html.inja" %}
+<header class="storage-header">
+    {% if storage.attachment_id %}
+    <img class="photo"
+         src="{{ url_for("attachment", storage.attachment_id) }}"
+         alt="">
+    {% endif %}
+    <h1>{{ storage.name }}</h1>
+    {% if storage.description %}
+    <p class="desc">{{ storage.description }}</p>
+    {% endif %}
+    <p class="actions">
+        <a class="btn" href="{{ url_for("storage_edit", storage.id) }}">Edit</a>
+        <a class="btn"
+           href="{{ url_for("storage_new") }}?parent_id={{ storage.id }}">+ Sub-storage</a>
+        <a class="btn"
+           href="{{ url_for("stuff_new") }}?storage_id={{ storage.id }}">+ Stuff</a>
+        <button class="btn-danger"
+                hx-delete="{{ url_for("storage", storage.id) }}"
+                hx-confirm="Delete this storage?"
+                hx-target="body">Delete</button>
+    </p>
+    <form class="upload"
+          action="{{ url_for("storage_photo", storage.id) }}"
+          method="post" enctype="multipart/form-data">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <input type="file" name="photo" accept="image/*" required>
+        <button type="submit">Upload photo</button>
+    </form>
+</header>
+
+<section>
+    <h2>Sub-storages</h2>
+    {% if length(children) == 0 %}
+    <p class="empty">None.</p>
+    {% else %}
+    <ul class="storage-children">
+        {% for c in children %}
+        <li><a href="{{ url_for("storage", c.id) }}">{{ c.name }}</a></li>
+        {% endfor %}
+    </ul>
+    {% endif %}
+</section>
+
+<section>
+    <h2>Stuff here</h2>
+    {% if length(stuffs) == 0 %}
+    <p class="empty">No stuff in this storage.</p>
+    {% else %}
+    <ul class="stuff-list">
+        {% for s in stuffs %}
+        <li><a href="{{ url_for("stuff", s.id) }}">{{ s.name }}</a></li>
+        {% endfor %}
+    </ul>
+    {% endif %}
+</section>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/templates/stuff_form.html.inja b/src/overseer/modules/inventory/templates/stuff_form.html.inja
new file mode 100644
index 0000000..8b0c3e3
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/stuff_form.html.inja
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>Stuff · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+<h1>{% if mode == "edit" %}Edit stuff{% else %}New stuff{% endif %}</h1>
+
+{% if mode == "edit" %}
+<form method="post"
+      action="{{ url_for("stuff", stuff.id) }}"
+      hx-put="{{ url_for("stuff", stuff.id) }}"
+      hx-target="body">
+{% else %}
+<form method="post" action="{{ url_for("stuff_create") }}">
+{% endif %}
+    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+    <label>Name
+        <input type="text" name="name" required
+            value="{% if mode == "edit" %}{{ stuff.name }}{% endif %}">
+    </label>
+    <label>Description
+        <textarea name="description" rows="4">{% if mode == "edit" %}{{ stuff.description }}{% endif %}</textarea>
+    </label>
+    <label>Storage ID
+        <input type="number" name="storage_id" required
+            value="{% if mode == "edit" %}{{ stuff.storage_id }}{% else if exists("storage_id") %}{{ storage_id }}{% endif %}">
+    </label>
+    <button type="submit">Save</button>
+</form>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/templates/stuff_show.html.inja b/src/overseer/modules/inventory/templates/stuff_show.html.inja
new file mode 100644
index 0000000..50680e6
--- /dev/null
+++ b/src/overseer/modules/inventory/templates/stuff_show.html.inja
@@ -0,0 +1,72 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>{% include "_head.html.inja" %}<title>{{ stuff.name }} · Overseer</title></head>
+<body>
+{% include "_topbar.html.inja" %}
+<main>
+<header class="stuff-header">
+    {% if stuff.attachment_id %}
+    <img class="photo"
+         src="{{ url_for("attachment", stuff.attachment_id) }}"
+         alt="">
+    {% endif %}
+    {% include "fragment_stuff_name.html.inja" %}
+    {% if stuff.description %}
+    <p class="desc">{{ stuff.description }}</p>
+    {% endif %}
+    {% if exists("current_storage") %}
+    <p class="location">
+        Currently in:
+        <a href="{{ url_for("storage", current_storage.id) }}">{{ current_storage.name }}</a>
+    </p>
+    {% endif %}
+    <p class="actions">
+        <a class="btn" href="{{ url_for("stuff_edit", stuff.id) }}">Edit</a>
+        <button class="btn-danger"
+                hx-delete="{{ url_for("stuff", stuff.id) }}"
+                hx-confirm="Delete this stuff?"
+                hx-target="body">Delete</button>
+    </p>
+    <form class="upload"
+          action="{{ url_for("stuff_photo", stuff.id) }}"
+          method="post" enctype="multipart/form-data">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <input type="file" name="photo" accept="image/*" required>
+        <button type="submit">Upload photo</button>
+    </form>
+    <form class="move"
+          hx-post="{{ url_for("stuff_move", stuff.id) }}"
+          hx-target="#move-status"
+          hx-swap="innerHTML">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <label>Move to storage id
+            <input type="number" name="storage_id" required>
+        </label>
+        <button type="submit">Move</button>
+    </form>
+    <div id="move-status"></div>
+</header>
+
+<section>
+    <h2>Move history</h2>
+    {% if length(moves) == 0 %}
+    <p class="empty">No prior locations.</p>
+    {% else %}
+    <ol class="moves">
+        {% for m in moves %}
+        <li>
+            <time>{{ m.moved_at }}</time>
+            was in
+            {% if existsIn(m, "storage_name") %}
+            <a href="{{ url_for("storage", m.storage_id) }}">{{ m.storage_name }}</a>
+            {% else %}
+            <em>(deleted)</em>
+            {% endif %}
+        </li>
+        {% endfor %}
+    </ol>
+    {% endif %}
+</section>
+</main>
+</body>
+</html>
diff --git a/src/overseer/modules/inventory/views.cpp b/src/overseer/modules/inventory/views.cpp
new file mode 100644
index 0000000..17c8fd3
--- /dev/null
+++ b/src/overseer/modules/inventory/views.cpp
@@ -0,0 +1,80 @@
+#include "views.hpp"
+
+namespace overseer::modules::inventory_module
+{
+
+nlohmann::json toJson(const ::overseer::inventory::Storage& s)
+{
+    nlohmann::json j;
+    j["id"] = s.id;
+    if(s.parent_id.has_value())
+    {
+        j["parent_id"] = *s.parent_id;
+    }
+    else
+    {
+        j["parent_id"] = nullptr;
+    }
+    j["name"] = s.name;
+    j["description"] = s.description.has_value() ? *s.description : "";
+    if(s.attachment_id.has_value())
+    {
+        j["attachment_id"] = *s.attachment_id;
+    }
+    else
+    {
+        j["attachment_id"] = nullptr;
+    }
+    return j;
+}
+
+nlohmann::json toJson(const ::overseer::inventory::Stuff& s)
+{
+    nlohmann::json j;
+    j["id"] = s.id;
+    j["name"] = s.name;
+    j["description"] = s.description.has_value() ? *s.description : "";
+    if(s.attachment_id.has_value())
+    {
+        j["attachment_id"] = *s.attachment_id;
+    }
+    else
+    {
+        j["attachment_id"] = nullptr;
+    }
+    j["storage_id"] = s.storage_id;
+    return j;
+}
+
+nlohmann::json
+storagesToJson(const std::vector<::overseer::inventory::Storage>& v)
+{
+    nlohmann::json arr = nlohmann::json::array();
+    for(const auto& s : v)
+    {
+        arr.push_back(toJson(s));
+    }
+    return arr;
+}
+
+nlohmann::json
+stuffsToJson(const std::vector<::overseer::inventory::Stuff>& v)
+{
+    nlohmann::json arr = nlohmann::json::array();
+    for(const auto& s : v)
+    {
+        arr.push_back(toJson(s));
+    }
+    return arr;
+}
+
+nlohmann::json baseData(const std::string& csrf_token,
+                        const std::string& user_name)
+{
+    nlohmann::json j;
+    j["csrf_token"] = csrf_token;
+    j["user_name"]  = user_name;
+    return j;
+}
+
+} // namespace overseer::modules::inventory_module
diff --git a/src/overseer/modules/inventory/views.hpp b/src/overseer/modules/inventory/views.hpp
new file mode 100644
index 0000000..c0e6334
--- /dev/null
+++ b/src/overseer/modules/inventory/views.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "inventory/repo.hpp"
+#include "inventory/storage.hpp"
+#include "inventory/stuff.hpp"
+
+namespace overseer::modules::inventory_module
+{
+
+nlohmann::json toJson(const ::overseer::inventory::Storage& s);
+nlohmann::json toJson(const ::overseer::inventory::Stuff& s);
+
+nlohmann::json
+storagesToJson(const std::vector<::overseer::inventory::Storage>& v);
+
+nlohmann::json
+stuffsToJson(const std::vector<::overseer::inventory::Stuff>& v);
+
+/// Build the base template data: csrf token (if any), current user, etc.
+nlohmann::json baseData(const std::string& csrf_token,
+                        const std::string& user_name);
+
+} // namespace overseer::modules::inventory_module
diff --git a/src/overseer/render.cpp b/src/overseer/render.cpp
new file mode 100644
index 0000000..c1c66e7
--- /dev/null
+++ b/src/overseer/render.cpp
@@ -0,0 +1,97 @@
+#include "render.hpp"
+
+#include <filesystem>
+#include <format>
+#include <memory>
+#include <mutex>
+#include <stdexcept>
+#include <string>
+
+#include <inja.hpp>
+#include <mw/error.hpp>
+#include <nlohmann/json.hpp>
+
+namespace overseer
+{
+
+Render::Render(std::string template_root, bool dev_reload)
+    : template_root_(std::move(template_root)),
+      dev_reload_(dev_reload),
+      env_(buildEnv())
+{}
+
+std::unique_ptr<inja::Environment> Render::buildEnv() const
+{
+    auto env = std::make_unique<inja::Environment>();
+    env->set_trim_blocks(true);
+    env->set_lstrip_blocks(true);
+    env->set_html_autoescape(true);
+
+    // url_for(name) and url_for(name, id) — single point of truth for
+    // URL shapes so templates don't drift if routes change.
+    env->add_callback("url_for", [](inja::Arguments& args) -> std::string
+    {
+        if(args.empty()) return "";
+        const auto name = args.at(0)->get<std::string>();
+
+        if(name == "index")          return "/inventory";
+        if(name == "search")         return "/inventory/search";
+        if(name == "storage_create") return "/inventory/storage";
+        if(name == "storage_new")    return "/inventory/storage/new";
+        if(name == "stuff_create")   return "/inventory/stuff";
+        if(name == "stuff_new")      return "/inventory/stuff/new";
+        if(name == "logout")         return "/oidc/logout";
+
+        if(args.size() < 2) return "";
+        const auto id = args.at(1)->get<int64_t>();
+        const auto sid = std::to_string(id);
+
+        if(name == "storage")          return "/inventory/storage/" + sid;
+        if(name == "storage_edit")     return "/inventory/storage/" + sid + "/edit";
+        if(name == "storage_children") return "/inventory/storage/" + sid + "/children";
+        if(name == "storage_photo")    return "/inventory/storage/" + sid + "/photo";
+        if(name == "stuff")            return "/inventory/stuff/" + sid;
+        if(name == "stuff_edit")       return "/inventory/stuff/" + sid + "/edit";
+        if(name == "stuff_photo")      return "/inventory/stuff/" + sid + "/photo";
+        if(name == "stuff_move")       return "/inventory/stuff/" + sid + "/move";
+        if(name == "stuff_name")       return "/inventory/stuff/" + sid + "/name";
+        if(name == "stuff_name_edit")  return "/inventory/stuff/" + sid + "/name/edit";
+        if(name == "stuff_name_view")  return "/inventory/stuff/" + sid + "/name";
+        if(name == "attachment")       return "/attachment/" + sid;
+        return "";
+    });
+    return env;
+}
+
+mw::E<std::string> Render::render(const std::string& module,
+                                  const std::string& template_name,
+                                  const nlohmann::json& data)
+{
+    std::filesystem::path path = std::filesystem::path(template_root_)
+        / module / "templates" / template_name;
+
+    inja::Environment* env;
+    std::unique_ptr<inja::Environment> tmp_env;
+    std::lock_guard lock(mtx_);
+    if(dev_reload_)
+    {
+        tmp_env = buildEnv();
+        env = tmp_env.get();
+    }
+    else
+    {
+        env = env_.get();
+    }
+
+    try
+    {
+        return env->render_file(path.string(), data);
+    }
+    catch(const std::exception& e)
+    {
+        return std::unexpected(mw::runtimeError(std::format(
+            "Template error ({}): {}", path.string(), e.what())));
+    }
+}
+
+} // namespace overseer
diff --git a/src/overseer/render.hpp b/src/overseer/render.hpp
new file mode 100644
index 0000000..d3a8702
--- /dev/null
+++ b/src/overseer/render.hpp
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <memory>
+#include <mutex>
+#include <string>
+
+#include <inja.hpp>
+#include <mw/error.hpp>
+#include <nlohmann/json.hpp>
+
+namespace overseer
+{
+
+/// Wraps an inja::Environment. In production it loads templates once at
+/// startup. In dev mode (`dev_reload = true`) each call to render()
+/// rebuilds the environment so template edits show up without a
+/// restart.
+class Render
+{
+public:
+    Render(std::string template_root, bool dev_reload);
+
+    mw::E<std::string> render(const std::string& module,
+                              const std::string& template_name,
+                              const nlohmann::json& data);
+
+private:
+    std::unique_ptr<inja::Environment> buildEnv() const;
+
+    std::string template_root_;   // e.g. ".../src/overseer/modules"
+    bool        dev_reload_;
+    std::mutex  mtx_;
+    std::unique_ptr<inja::Environment> env_;
+};
+
+} // namespace overseer
diff --git a/src/overseer/server.cpp b/src/overseer/server.cpp
new file mode 100644
index 0000000..1120c90
--- /dev/null
+++ b/src/overseer/server.cpp
@@ -0,0 +1,215 @@
+#include "server.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <format>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include <httplib.h>
+#include <mw/auth.hpp>
+#include <mw/error.hpp>
+#include <mw/http_client.hpp>
+#include <mw/http_server.hpp>
+#include <mw/url.hpp>
+#include <spdlog/spdlog.h>
+
+#include "config.hpp"
+
+namespace overseer
+{
+
+mw::HTTPServer::ListenAddress
+buildListenAddress(const Config& config)
+{
+    if(config.bind_address.starts_with("unix:"))
+    {
+        mw::SocketFileInfo info(
+            std::string_view(config.bind_address).substr(5));
+        return info;
+    }
+    return mw::IPSocketInfo{config.bind_address, config.port};
+}
+
+std::unique_ptr<mw::AuthOpenIDConnect>
+buildAuthOrDie(const Config& config)
+{
+    auto auth = mw::AuthOpenIDConnect::create(
+        config.oidc.issuer_url,
+        config.oidc.client_id,
+        config.oidc.client_secret,
+        config.oidc.redirect_uri,
+        std::make_unique<mw::HTTPSession>());
+    if(!auth.has_value())
+    {
+        spdlog::error("Failed to set up OIDC: {}",
+                      mw::errorMsg(auth.error()));
+        std::abort();
+    }
+    return std::move(*auth);
+}
+
+namespace
+{
+
+std::string deriveExpectedOrigin(const std::string& redirect_uri)
+{
+    auto u = mw::URL::fromStr(redirect_uri);
+    if(!u.has_value())
+    {
+        spdlog::warn("redirect_uri '{}' is not a parseable URL; "
+                     "same-origin check disabled.", redirect_uri);
+        return "";
+    }
+    std::string origin = u->scheme() + "://" + u->host();
+    // No standard accessor for the port; for now use scheme://host.
+    // If the deployment hosts on a non-default port, the operator must
+    // ensure the reverse proxy passes a matching Origin header host.
+    return origin;
+}
+
+// Per-thread request-start timestamp. The pre-routing handler stamps
+// it; the logger callback (which runs on the same thread) reads it.
+std::chrono::steady_clock::time_point& requestStart()
+{
+    thread_local std::chrono::steady_clock::time_point t{};
+    return t;
+}
+
+} // namespace
+
+Server::Server(const Config& config,
+               mw::SQLite& db,
+               const std::string& static_dir,
+               const std::string& template_dir,
+               bool dev_mode)
+    : mw::HTTPServer(buildListenAddress(config)),
+      config_(config),
+      db_(db),
+      static_dir_(static_dir),
+      expected_origin_(deriveExpectedOrigin(config.oidc.redirect_uri)),
+      sessions_(config.session.max_age),
+      repo_(db),
+      render_(template_dir, dev_mode),
+      auth_handler_(config, buildAuthOrDie(config), sessions_),
+      attachment_handler_(repo_)
+{}
+
+Server::Server(const Config& config,
+               mw::SQLite& db,
+               const std::string& static_dir,
+               const std::string& template_dir,
+               bool dev_mode,
+               std::unique_ptr<mw::AuthInterface> auth)
+    : mw::HTTPServer(buildListenAddress(config)),
+      config_(config),
+      db_(db),
+      static_dir_(static_dir),
+      expected_origin_(deriveExpectedOrigin(config.oidc.redirect_uri)),
+      sessions_(config.session.max_age),
+      repo_(db),
+      render_(template_dir, dev_mode),
+      auth_handler_(config, std::move(auth), sessions_),
+      attachment_handler_(repo_)
+{}
+
+void Server::setup()
+{
+    server.set_default_headers({
+        {"X-Content-Type-Options", "nosniff"},
+        {"Referrer-Policy", "same-origin"},
+    });
+    server.set_payload_max_length(20 * 1024 * 1024); // 20 MiB
+
+    server.set_exception_handler(
+        [](const httplib::Request&, httplib::Response& res,
+           std::exception_ptr eptr)
+    {
+        try
+        {
+            std::rethrow_exception(eptr);
+        }
+        catch(const std::exception& e)
+        {
+            spdlog::error("Unhandled exception in handler: {}", e.what());
+        }
+        catch(...)
+        {
+            spdlog::error("Unhandled non-std exception in handler");
+        }
+        res.status = 500;
+        res.set_content("Something went wrong", "text/plain");
+    });
+
+    server.set_logger([this](const httplib::Request& req,
+                              const httplib::Response& res)
+    {
+        const auto now = std::chrono::steady_clock::now();
+        const auto start = requestStart();
+        const auto dur_ms = start.time_since_epoch().count() == 0
+            ? 0.0
+            : std::chrono::duration<double, std::milli>(now - start).count();
+        const auto uid = auth_handler_.userIdForRequest(req)
+            .value_or(std::string{"-"});
+        spdlog::info("{} {} -> {} {:.2f}ms {}B user={}",
+                     req.method, req.path, res.status,
+                     dur_ms, res.body.size(), uid);
+    });
+
+    // Trailing-slash redirect, e.g. /inventory/ -> /inventory. Runs
+    // before route matching via cpp-httplib's pre-routing handler.
+    server.set_pre_routing_handler(
+        [](const httplib::Request& req, httplib::Response& res)
+    {
+        requestStart() = std::chrono::steady_clock::now();
+        if(req.path.size() > 1 && req.path.back() == '/')
+        {
+            std::string target = req.path.substr(0, req.path.size() - 1);
+            if(!req.params.empty())
+            {
+                std::string q;
+                for(const auto& [k, v] : req.params)
+                {
+                    if(!q.empty()) q += "&";
+                    q += k + "=" + v;
+                }
+                target += "?" + q;
+            }
+            res.status = 301;
+            res.set_header("Location", target);
+            return httplib::Server::HandlerResponse::Handled;
+        }
+        return httplib::Server::HandlerResponse::Unhandled;
+    });
+
+    auth_handler_.registerRoutes(server);
+    attachment_handler_.registerRoutes(server);
+
+    for(auto& mod : modules_)
+    {
+        mod->registerRoutes(server, *this);
+    }
+
+    // Static files.
+    if(!static_dir_.empty() &&
+       std::filesystem::is_directory(static_dir_))
+    {
+        server.set_mount_point("/static", static_dir_);
+    }
+
+    // Root redirect: redirect "/" -> "/inventory"
+    server.Get("/", [](const httplib::Request&, httplib::Response& res)
+    {
+        res.status = 302;
+        res.set_header("Location", "/inventory");
+    });
+
+    server.Get("/healthz", [](const httplib::Request&,
+                              httplib::Response& res)
+    {
+        res.status = 204;
+    });
+}
+
+} // namespace overseer
diff --git a/src/overseer/server.hpp b/src/overseer/server.hpp
new file mode 100644
index 0000000..d450d78
--- /dev/null
+++ b/src/overseer/server.hpp
@@ -0,0 +1,77 @@
+#pragma once
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/http_server.hpp>
+
+#include "attachment_handler.hpp"
+#include "auth_handler.hpp"
+#include "config.hpp"
+#include "inventory/repo.hpp"
+#include "module.hpp"
+#include "render.hpp"
+#include "session.hpp"
+
+namespace overseer
+{
+
+class Server : public mw::HTTPServer
+{
+public:
+    Server(const Config& config,
+           mw::SQLite& db,
+           const std::string& static_dir,
+           const std::string& template_dir,
+           bool dev_mode);
+
+    /// Test-friendly constructor: caller supplies the AuthInterface
+    /// instead of having the Server build an AuthOpenIDConnect. Set
+    /// `config.oidc.issuer_url` to "" to skip end-session discovery.
+    Server(const Config& config,
+           mw::SQLite& db,
+           const std::string& static_dir,
+           const std::string& template_dir,
+           bool dev_mode,
+           std::unique_ptr<mw::AuthInterface> auth);
+
+    // Accessors used by route handlers.
+    inventory::InventoryRepo& repo()        { return repo_; }
+    Render&                   render()      { return render_; }
+    AuthHandler&              auth()        { return auth_handler_; }
+    const Config&             config() const { return config_; }
+
+    // "scheme://host[:port]" derived from config.oidc.redirect_uri,
+    // used by mutating endpoints as their expected Origin/Referer.
+    const std::string&        expectedOrigin() const { return expected_origin_; }
+
+    void setModules(std::vector<std::unique_ptr<Module>> mods)
+    {
+        modules_ = std::move(mods);
+    }
+
+protected:
+    void setup() override;
+
+private:
+    const Config& config_;
+    mw::SQLite& db_;
+    std::string static_dir_;
+    std::string expected_origin_;
+    SessionStore sessions_;
+    inventory::InventoryRepo repo_;
+    Render render_;
+    AuthHandler auth_handler_;
+    AttachmentHandler attachment_handler_;
+    std::vector<std::unique_ptr<Module>> modules_;
+};
+
+mw::HTTPServer::ListenAddress
+buildListenAddress(const Config& config);
+
+std::unique_ptr<mw::AuthOpenIDConnect>
+buildAuthOrDie(const Config& config);
+
+} // namespace overseer
diff --git a/src/overseer/session.cpp b/src/overseer/session.cpp
new file mode 100644
index 0000000..5642f78
--- /dev/null
+++ b/src/overseer/session.cpp
@@ -0,0 +1,192 @@
+#include "session.hpp"
+
+#include <chrono>
+#include <cstddef>
+#include <cstdio>
+#include <fstream>
+#include <mutex>
+#include <optional>
+#include <span>
+#include <string>
+#include <vector>
+
+#include <mw/utils.hpp>
+
+namespace overseer
+{
+
+namespace
+{
+
+std::string toBase64UrlNoPad(std::string_view std_b64)
+{
+    std::string out;
+    out.reserve(std_b64.size());
+    for(char c : std_b64)
+    {
+        if(c == '+')      out.push_back('-');
+        else if(c == '/') out.push_back('_');
+        else if(c == '=') continue;
+        else if(c == '\n' || c == '\r') continue;
+        else              out.push_back(c);
+    }
+    return out;
+}
+
+} // namespace
+
+SessionStore::SessionStore(std::chrono::minutes default_lifetime)
+    : default_lifetime_(default_lifetime)
+{}
+
+std::string SessionStore::randomToken(size_t nbytes)
+{
+    std::vector<unsigned char> buf(nbytes);
+    std::ifstream urandom("/dev/urandom", std::ios::binary);
+    urandom.read(reinterpret_cast<char*>(buf.data()), nbytes);
+    if(!urandom)
+    {
+        // Fall back to time-based: still random enough for a 2-user
+        // app, but log loudly elsewhere if this ever fires.
+        auto t = std::chrono::system_clock::now().time_since_epoch().count();
+        for(size_t i = 0; i < nbytes; ++i)
+        {
+            buf[i] = static_cast<unsigned char>(
+                (t >> (8 * (i % 8))) & 0xff);
+        }
+    }
+    auto std_b64 = mw::base64Encode(std::span<unsigned char>(buf), false, false);
+    return toBase64UrlNoPad(std_b64);
+}
+
+void SessionStore::rememberPendingLogin(const std::string& state,
+                                        std::string return_to)
+{
+    std::lock_guard lock(mtx_);
+    pending_[state] = {std::move(return_to),
+                       std::chrono::system_clock::now()};
+}
+
+std::optional<PendingLogin>
+SessionStore::consumePendingLogin(const std::string& state)
+{
+    std::lock_guard lock(mtx_);
+    auto it = pending_.find(state);
+    if(it == pending_.end())
+    {
+        return std::nullopt;
+    }
+    auto pl = std::move(it->second);
+    pending_.erase(it);
+
+    // 5-minute window per design doc.
+    if(std::chrono::system_clock::now() - pl.created_at >
+       std::chrono::minutes(5))
+    {
+        return std::nullopt;
+    }
+    return pl;
+}
+
+std::string SessionStore::create(mw::UserInfo user, mw::Tokens tokens)
+{
+    std::lock_guard lock(mtx_);
+    purgeExpired();
+
+    Session s;
+    s.id         = randomToken(32);
+    s.user       = std::move(user);
+    s.tokens     = std::move(tokens);
+    s.csrf_token = randomToken(16);
+    s.created_at = std::chrono::system_clock::now();
+    s.expires_at = s.created_at + default_lifetime_;
+
+    const std::string id = s.id;
+    sessions_[id] = std::move(s);
+    return id;
+}
+
+void SessionStore::drop(const std::string& sid)
+{
+    std::lock_guard lock(mtx_);
+    sessions_.erase(sid);
+}
+
+std::optional<Session> SessionStore::get(const std::string& sid)
+{
+    if(sid.empty())
+    {
+        return std::nullopt;
+    }
+    std::lock_guard lock(mtx_);
+    auto it = sessions_.find(sid);
+    if(it == sessions_.end())
+    {
+        return std::nullopt;
+    }
+    if(std::chrono::system_clock::now() > it->second.expires_at)
+    {
+        sessions_.erase(it);
+        return std::nullopt;
+    }
+    return it->second;
+}
+
+void SessionStore::replaceTokens(const std::string& sid, mw::Tokens tokens)
+{
+    std::lock_guard lock(mtx_);
+    auto it = sessions_.find(sid);
+    if(it == sessions_.end())
+    {
+        return;
+    }
+    it->second.tokens = std::move(tokens);
+}
+
+std::optional<std::string> SessionStore::peekUserId(const std::string& sid)
+{
+    if(sid.empty())
+    {
+        return std::nullopt;
+    }
+    std::lock_guard lock(mtx_);
+    auto it = sessions_.find(sid);
+    if(it == sessions_.end())
+    {
+        return std::nullopt;
+    }
+    if(std::chrono::system_clock::now() > it->second.expires_at)
+    {
+        return std::nullopt;
+    }
+    return it->second.user.id;
+}
+
+void SessionStore::purgeExpired()
+{
+    const auto now = std::chrono::system_clock::now();
+    for(auto it = sessions_.begin(); it != sessions_.end();)
+    {
+        if(it->second.expires_at < now)
+        {
+            it = sessions_.erase(it);
+        }
+        else
+        {
+            ++it;
+        }
+    }
+    for(auto it = pending_.begin(); it != pending_.end();)
+    {
+        if(now - it->second.created_at > std::chrono::minutes(5))
+        {
+            it = pending_.erase(it);
+        }
+        else
+        {
+            ++it;
+        }
+    }
+}
+
+} // namespace overseer
diff --git a/src/overseer/session.hpp b/src/overseer/session.hpp
new file mode 100644
index 0000000..761c721
--- /dev/null
+++ b/src/overseer/session.hpp
@@ -0,0 +1,67 @@
+#pragma once
+
+#include <chrono>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <unordered_map>
+
+#include <mw/auth.hpp>
+#include <mw/utils.hpp>
+
+namespace overseer
+{
+
+struct Session
+{
+    std::string id;                   // opaque session id (cookie value)
+    mw::UserInfo user;
+    mw::Tokens   tokens;
+    std::string  csrf_token;          // 16 random bytes, base64url
+    mw::Time     created_at;
+    mw::Time     expires_at;
+};
+
+/// Pending OIDC-login state, indexed by the `state` query parameter
+/// that the IdP echoes back.
+struct PendingLogin
+{
+    std::string return_to;
+    mw::Time    created_at;
+};
+
+class SessionStore
+{
+public:
+    explicit SessionStore(std::chrono::minutes default_lifetime);
+
+    // ---- pending logins ----------------------------------------------
+    void          rememberPendingLogin(const std::string& state,
+                                       std::string return_to);
+    std::optional<PendingLogin> consumePendingLogin(const std::string& state);
+
+    // ---- sessions ----------------------------------------------------
+    std::string   create(mw::UserInfo user, mw::Tokens tokens);
+    void          drop(const std::string& sid);
+    std::optional<Session> get(const std::string& sid);
+    void          replaceTokens(const std::string& sid, mw::Tokens tokens);
+
+    // Non-mutating accessor: returns the user id for a session without
+    // running the lazy-token-refresh side effect of sessionForRequest.
+    // Used by the access logger so each request doesn't trigger a token
+    // refresh.
+    std::optional<std::string> peekUserId(const std::string& sid);
+
+    // ---- helpers -----------------------------------------------------
+    static std::string randomToken(size_t nbytes);
+
+private:
+    void purgeExpired();
+
+    std::chrono::minutes default_lifetime_;
+    std::mutex mtx_;
+    std::unordered_map<std::string, Session>      sessions_;
+    std::unordered_map<std::string, PendingLogin> pending_;
+};
+
+} // namespace overseer
diff --git a/src/static/css/overseer.css b/src/static/css/overseer.css
new file mode 100644
index 0000000..3983fe5
--- /dev/null
+++ b/src/static/css/overseer.css
@@ -0,0 +1,105 @@
+/* Overseer — plain CSS, no framework. */
+*, *::before, *::after { box-sizing: border-box; }
+:root {
+    --bg: #ffffff;
+    --fg: #1c1c1f;
+    --muted: #5b5b66;
+    --accent: #2563eb;
+    --accent-fg: #ffffff;
+    --danger: #b91c1c;
+    --border: #d6d6e0;
+    --panel: #f7f7fb;
+}
+@media (prefers-color-scheme: dark) {
+    :root {
+        --bg: #0f0f12;
+        --fg: #ebebef;
+        --muted: #a0a0aa;
+        --accent: #60a5fa;
+        --accent-fg: #0a0a10;
+        --danger: #f87171;
+        --border: #2a2a32;
+        --panel: #15151b;
+    }
+}
+html, body { margin: 0; }
+body {
+    background: var(--bg);
+    color: var(--fg);
+    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+    line-height: 1.45;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+main { padding: 1.5rem; max-width: 60rem; margin: 0 auto; }
+
+.topbar {
+    display: flex; align-items: center; gap: 1rem;
+    padding: 0.75rem 1.5rem;
+    border-bottom: 1px solid var(--border);
+    background: var(--panel);
+}
+.topbar .brand a {
+    font-weight: 600; color: var(--fg);
+}
+.topbar .search { flex: 1; }
+.topbar .search input[type=search] {
+    width: 100%; padding: 0.5rem 0.75rem;
+    border: 1px solid var(--border); border-radius: 0.4rem;
+    background: var(--bg); color: var(--fg);
+}
+.topbar .user { display: flex; align-items: center; gap: 0.5rem; }
+.topbar .user form.logout { margin: 0; }
+
+h1, h2, h3 { line-height: 1.2; }
+h1 { margin-top: 0; }
+
+.tree, .tree-children, .storage-children, .stuff-list, .moves {
+    list-style: none; padding-left: 1rem;
+}
+.tree details > summary { cursor: pointer; padding: 0.2rem 0; }
+.tree-children { padding-left: 1rem; }
+
+.btn, .btn-danger {
+    display: inline-block;
+    padding: 0.35rem 0.75rem;
+    border: 1px solid var(--border);
+    border-radius: 0.35rem;
+    background: var(--bg); color: var(--fg);
+    cursor: pointer; font: inherit;
+}
+.btn-danger { color: var(--danger); border-color: var(--danger); }
+.btn:hover { background: var(--panel); }
+
+.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
+
+form label {
+    display: block; margin: 0.75rem 0;
+}
+form label input, form label textarea, form label select {
+    display: block; width: 100%;
+    padding: 0.45rem 0.6rem;
+    border: 1px solid var(--border); border-radius: 0.35rem;
+    background: var(--bg); color: var(--fg);
+    font: inherit;
+}
+form button[type=submit] {
+    background: var(--accent); color: var(--accent-fg);
+    border: none; border-radius: 0.35rem;
+    padding: 0.5rem 1rem; font: inherit; cursor: pointer;
+}
+
+img.photo {
+    max-width: 100%; height: auto;
+    border-radius: 0.5rem; border: 1px solid var(--border);
+}
+
+.breadcrumbs { color: var(--muted); margin-bottom: 0.75rem; }
+.breadcrumbs .sep { margin: 0 0.4rem; }
+.breadcrumbs .here { color: var(--fg); font-weight: 600; }
+
+.empty { color: var(--muted); font-style: italic; }
+.search-results ul { list-style: none; padding-left: 0; }
+.search-results li { padding: 0.2rem 0; }
+
+form.upload { margin: 0.75rem 0; display: flex; gap: 0.5rem; align-items: center; }
diff --git a/src/static/js/HTMX_VERSION.txt b/src/static/js/HTMX_VERSION.txt
new file mode 100644
index 0000000..1bfd6ff
--- /dev/null
+++ b/src/static/js/HTMX_VERSION.txt
@@ -0,0 +1,5 @@
+htmx 1.9.12 — vendored from https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js
+
+Replace htmx.min.js in this directory with the official minified build
+of the listed version. We deliberately do not check the minified bytes
+into source control until the operator drops the file in.
diff --git a/src/static/js/htmx.min.js b/src/static/js/htmx.min.js
new file mode 100644
index 0000000..de5f0f1
--- /dev/null
+++ b/src/static/js/htmx.min.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/<body/.test(e)}function l(e){var t=!N(e);var r=A(e);var n=e;if(r==="head"){n=n.replace(S,"")}if(Q.config.useTemplateFragments&&t){var i=s("<body><template>"+n+"</template></body>",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("<table>"+n+"</table>",1);case"col":return s("<table><colgroup>"+n+"</colgroup></table>",2);case"tr":return s("<table><tbody>"+n+"</tbody></table>",2);case"td":case"th":return s("<table><tbody><tr>"+n+"</tr></tbody></table>",3);case"script":case"style":return s("<div>"+n+"</div>",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function oe(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function X(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n<r.length;n++){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING){return i}}};var Y=function(e,t){var r=re().querySelectorAll(t);for(var n=r.length-1;n>=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r<t.length;r++){if(e===t[r]){return true}}return false}function we(t,r){oe(t.attributes,function(e){if(!r.hasAttribute(e.name)&&be(e.name)){t.removeAttribute(e.name)}});oe(r.attributes,function(e){if(be(e.name)){t.setAttribute(e.name,e.value)}})}function Se(e,t){var r=Fr(t);for(var n=0;n<r.length;n++){var i=r[n];try{if(i.isInlineSwap(e)){return true}}catch(e){b(e)}}return e==="outerHTML"}function Ee(e,i,a){var t="#"+ee(i,"id");var o="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a<i.length;a++){var o=i[a].split(":",2);var s=o[0].trim();if(s.indexOf("#")===0){s=s.substring(1)}var l=o[1]||"true";var u=t.querySelector("#"+s);if(u){Ee(l,u,r)}}}oe(f(t,"[hx-swap-oob], [data-hx-swap-oob]"),function(e){var t=te(e,"hx-swap-oob");if(t!=null){Ee(t,e,r)}})}function Re(e){oe(f(e,"[hx-preserve], [data-hx-preserve]"),function(e){var t=te(e,"id");var r=re().getElementById(t);if(r!=null){e.parentNode.replaceChild(r,e)}})}function Te(o,e,s){oe(e.querySelectorAll("[id]"),function(e){var t=ee(e,"id");if(t&&t.length>0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r<e.length){t=(t<<5)-t+e.charCodeAt(r++)|0}return t}function Le(e){var t=0;if(e.attributes){for(var r=0;r<e.attributes.length;r++){var n=e.attributes[r];if(n.value){t=He(n.name,t);t=He(n.value,t)}}}return t}function Ae(e){var t=ae(e);if(t.onHandlers){for(var r=0;r<t.onHandlers.length;r++){const n=t.onHandlers[r];e.removeEventListener(n.event,n.listener)}delete t.onHandlers}}function Ne(e){var t=ae(e);if(t.timeout){clearTimeout(t.timeout)}if(t.webSocket){t.webSocket.close()}if(t.sseEventSource){t.sseEventSource.close()}if(t.listenerInfos){oe(t.listenerInfos,function(e){if(e.on){e.on.removeEventListener(e.trigger,e.listener)}})}Ae(e);oe(Object.keys(t),function(e){delete t[e]})}function m(e){ce(e,"htmx:beforeCleanupElement");Ne(e);if(e.children){oe(e.children,function(e){m(e)})}}function Ie(t,e,r){if(t.tagName==="BODY"){return Ue(t,e,r)}else{var n;var i=t.previousSibling;a(u(t),t,e,r);if(i==null){n=u(t).firstChild}else{n=i.nextSibling}r.elts=r.elts.filter(function(e){return e!=t});while(n&&n!==t){if(n.nodeType===Node.ELEMENT_NODE){r.elts.push(n)}n=n.nextElementSibling}m(t);u(t).removeChild(t)}}function ke(e,t,r){return a(e,e.firstChild,t,r)}function Pe(e,t,r){return a(u(e),e,t,r)}function Me(e,t,r){return a(e,null,t,r)}function Xe(e,t,r){return a(u(e),e.nextSibling,t,r)}function De(e,t,r){m(e);return u(e).removeChild(e)}function Ue(e,t,r){var n=e.firstChild;a(e,n,t,r);if(n){while(n.nextSibling){m(n.nextSibling);e.removeChild(n.nextSibling)}m(n);e.removeChild(n)}}function Be(e,t,r){var n=r||ne(e,"hx-select");if(n){var i=re().createDocumentFragment();oe(t.querySelectorAll(n),function(e){i.appendChild(e)});t=i}return t}function Fe(e,t,r,n,i){switch(e){case"none":return;case"outerHTML":Ie(r,n,i);return;case"afterbegin":ke(r,n,i);return;case"beforebegin":Pe(r,n,i);return;case"beforeend":Me(r,n,i);return;case"afterend":Xe(r,n,i);return;case"delete":De(r,n,i);return;default:var a=Fr(t);for(var o=0;o<a.length;o++){var s=a[o];try{var l=s.handleSwap(e,r,n,i);if(l){if(typeof l.length!=="undefined"){for(var u=0;u<l.length;u++){var f=l[u];if(f.nodeType!==Node.TEXT_NODE&&f.nodeType!==Node.COMMENT_NODE){i.tasks.push(Oe(f))}}}return}}catch(e){b(e)}}if(e==="innerHTML"){Ue(r,n,i)}else{Fe(Q.config.defaultSwapStyle,t,r,n,i)}}}function Ve(e){if(e.indexOf("<title")>-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l<s.length;l++){ce(r,s[l].trim(),[])}}}var ze=/\s/;var x=/[\s,]/;var $e=/[_$a-zA-Z]/;var We=/[_$a-zA-Z0-9]/;var Ge=['"',"'","/"];var Je=/[^\s]/;var Ze=/[{(]/;var Ke=/[})]/;function Ye(e){var t=[];var r=0;while(r<e.length){if($e.exec(e.charAt(r))){var n=r;while(We.exec(e.charAt(r+1))){r++}t.push(e.substr(n,r-n+1))}else if(Ge.indexOf(e.charAt(r))!==-1){var i=e.charAt(r);var n=r;r++;while(r<e.length&&e.charAt(r)!==i){if(e.charAt(r)==="\\"){r++}r++}t.push(e.substr(n,r-n+1))}else{var a=e.charAt(r);t.push(a)}r++}return t}function Qe(e,t,r){return $e.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==r&&t!=="."}function et(e,t,r){if(t[0]==="["){t.shift();var n=1;var i=" return (function("+r+"){ return (";var a=null;while(t.length>0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i<n.length;i++){var a=n[i].split(/:(.+)/);if(a[0]==="connect"){xt(e,a[1],0)}if(a[0]==="send"){bt(e)}}}function xt(s,r,n){if(!se(s)){return}if(r.indexOf("/")==0){var e=location.hostname+(location.port?":"+location.port:"");if(location.protocol=="https:"){r="wss://"+e+r}else if(location.protocol=="http:"){r="ws://"+e+r}}var t=Q.createWebSocket(r);t.onerror=function(e){fe(s,"htmx:wsError",{error:e,socket:t});yt(s)};t.onclose=function(e){if([1006,1012,1013].indexOf(e.code)>=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a<i.length;a++){var o=i[a];Ee(te(o,"hx-swap-oob")||"true",o,r)}nr(r.tasks)})}function yt(e){if(!se(e)){ae(e).webSocket.close();return true}}function bt(u){var f=c(u,function(e){return ae(e).webSocket!=null});if(f){u.addEventListener(it(u)[0].trigger,function(e){var t=ae(f).webSocket;var r=xr(u,f);var n=dr(u,"post");var i=n.errors;var a=n.values;var o=Hr(u);var s=le(a,o);var l=yr(s,u);l["HEADERS"]=r;if(i&&i.length>0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i<n.length;i++){var a=n[i].split(/:(.+)/);if(a[0]==="connect"){Et(e,a[1])}if(a[0]==="swap"){Ct(e,a[1])}}}function Et(t,e){var r=Q.createEventSource(e);r.onerror=function(e){fe(t,"htmx:sseError",{error:e,source:r});Tt(t)};ae(t).sseEventSource=r}function Ct(a,o){var s=c(a,Ot);if(s){var l=ae(s).sseEventSource;var u=function(e){if(Tt(s)){return}if(!se(a)){l.removeEventListener(o,u);return}var t=e.data;R(a,function(e){t=e.transformResponse(t,null,a)});var r=wr(a);var n=ye(a);var i=T(a);je(r.swapStyle,n,a,t,i);nr(i.tasks);ce(a,"htmx:sseMessage",e)};ae(a).sseListener=u;l.addEventListener(o,u)}else{fe(a,"htmx:noSSESourceError")}}function Rt(e,t,r){var n=c(e,Ot);if(n){var i=ae(n).sseEventSource;var a=function(){if(!Tt(n)){if(se(e)){t(e)}else{i.removeEventListener(r,a)}}};ae(e).sseListener=a;i.addEventListener(r,a)}else{fe(e,"htmx:noSSESourceError")}}function Tt(e){if(!se(e)){ae(e).sseEventSource.close();return true}}function Ot(e){return ae(e).sseEventSource!=null}function qt(e,t,r,n){var i=function(){if(!r.loaded){r.loaded=true;t(e)}};if(n>0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t<e.length;t++){var r=e[t];if(r.isIntersecting){ce(n,"intersect");break}}},i);a.observe(n);ht(n,r,t,e)}else if(e.trigger==="load"){if(!ct(e,n,Wt("load",{elt:n}))){qt(n,r,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r<t.length;r++){var n=t[r].name;if(g(n,"hx-on:")||g(n,"data-hx-on:")||g(n,"hx-on-")||g(n,"data-hx-on-")){return true}}return false}function kt(e){var t=null;var r=[];if(It(e)){r.push(e)}if(document.evaluate){var n=document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]',e);while(t=n.iterateNext())r.push(t)}else if(typeof e.getElementsByTagName==="function"){var i=e.getElementsByTagName("*");for(var a=0;a<i.length;a++){if(It(i[a])){r.push(i[a])}}}return r}function Pt(e){if(e.querySelectorAll){var t=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";var r=e.querySelectorAll(i+t+", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws],"+" [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");return r}else{return[]}}function Mt(e){var t=v(e.target,"button, input[type='submit']");var r=Dt(e);if(r){r.lastButtonClicked=t}}function Xt(e){var t=Dt(e);if(t){t.lastButtonClicked=null}}function Dt(e){var t=v(e.target,"button, input[type='submit']");if(!t){return}var r=p("#"+ee(t,"form"))||v(t,"form");if(!r){return}return ae(r)}function Ut(e){e.addEventListener("click",Mt);e.addEventListener("focusin",Mt);e.addEventListener("focusout",Xt)}function Bt(e){var t=Ye(e);var r=0;for(var n=0;n<t.length;n++){const i=t[n];if(i==="{"){r++}else if(i==="}"){r--}}return r}function Ft(t,e,r){var n=ae(t);if(!Array.isArray(n.onHandlers)){n.onHandlers=[]}var i;var a=function(e){return Tr(t,function(){if(!i){i=new Function("event",r)}i.call(t,e)})};t.addEventListener(e,a);n.onHandlers.push({event:e,listener:a})}function Vt(e){var t=te(e,"hx-on");if(t){var r={};var n=t.split("\n");var i=null;var a=0;while(n.length>0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;t<e.attributes.length;t++){var r=e.attributes[t].name;var n=e.attributes[t].value;if(g(r,"hx-on")||g(r,"data-hx-on")){var i=r.indexOf("-on")+3;var a=r.slice(i,i+1);if(a==="-"||a===":"){var o=r.slice(i+1);if(g(o,":")){o="htmx"+o}else if(g(o,"-")){o="htmx:"+o.slice(1)}else if(g(o,"htmx-")){o="htmx:"+o.slice(5)}Ft(e,o,n)}}}}function _t(t){if(v(t,Q.config.disableSelector)){m(t);return}var r=ae(t);if(r.initHash!==Le(t)){Ne(t);r.initHash=Le(t);Vt(t);ce(t,"htmx:beforeProcessNode");if(t.value){r.lastValue=t.value}var e=it(t);var n=Ht(t,r,e);if(!n){if(ne(t,"hx-boost")==="true"){lt(t,r,e)}else if(o(t,"hx-trigger")){e.forEach(function(e){Lt(t,e,r,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&o(t,"form")){Ut(t)}var i=te(t,"hx-sse");if(i){St(t,r,i)}var a=te(t,"hx-ws");if(a){mt(t,r,a)}ce(t,"htmx:afterProcessNode")}}function zt(e){e=p(e);if(v(e,Q.config.disableSelector)){m(e);return}_t(e);oe(Pt(e),function(e){_t(e)});oe(kt(e),jt)}function $t(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Wt(e,t){var r;if(window.CustomEvent&&typeof window.CustomEvent==="function"){r=new CustomEvent(e,{bubbles:true,cancelable:true,detail:t})}else{r=re().createEvent("CustomEvent");r.initCustomEvent(e,true,true,t)}return r}function fe(e,t,r){ce(e,t,le({error:t},r))}function Gt(e){return e==="htmx:afterProcessNode"}function R(e,t){oe(Fr(e),function(e){try{t(e)}catch(e){b(e)}})}function b(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function ce(e,t,r){e=p(e);if(r==null){r={}}r["elt"]=e;var n=Wt(t,r);if(Q.logger&&!Gt(t)){Q.logger(e,t,r)}if(r.error){b(r.error);ce(e,"htmx:error",{errorInfo:r})}var i=e.dispatchEvent(n);var a=$t(t);if(i&&a!==t){var o=Wt(a,n.detail);i=i&&e.dispatchEvent(o)}R(e,function(e){i=i&&(e.onEvent(t,n)!==false&&!n.defaultPrevented)});return i}var Jt=location.pathname+location.search;function Zt(){var e=re().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||re().body}function Kt(e,t,r,n){if(!U()){return}if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}e=B(e);var i=E(localStorage.getItem("htmx-history-cache"))||[];for(var a=0;a<i.length;a++){if(i[a].url===e){i.splice(a,1);break}}var o={url:e,content:t,title:r,scroll:n};ce(re().body,"htmx:historyItemCreated",{item:o,cache:i});i.push(o);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r<t.length;r++){if(t[r].url===e){return t[r]}}return null}function Qt(e){var t=Q.config.requestClass;var r=e.cloneNode(true);oe(f(r,"."+t),function(e){n(e,t)});return r.innerHTML}function er(){var e=Zt();var t=Jt||location.pathname+location.search;var r;try{r=re().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){r=re().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!r){ce(re().body,"htmx:beforeHistorySave",{path:t,historyElt:e});Kt(t,Qt(e),re().title,window.scrollY)}if(Q.config.historyEnabled)history.replaceState({htmx:true},re().title,window.location.href)}function tr(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(G(e,"&")||G(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Jt=e}function rr(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Jt=e}function nr(e){oe(e,function(e){e.call()})}function ir(a){var e=new XMLHttpRequest;var o={path:a,xhr:e};ce(re().body,"htmx:historyCacheMiss",o);e.open("GET",a,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",re().location.href);e.onload=function(){if(this.status>=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r<e.length;r++){var n=e[r];if(n.isSameNode(t)){return true}}return false}function fr(e){if(e.name===""||e.name==null||e.disabled||v(e,"fieldset[disabled]")){return false}if(e.type==="button"||e.type==="submit"||e.tagName==="image"||e.tagName==="reset"||e.tagName==="file"){return false}if(e.type==="checkbox"||e.type==="radio"){return e.checked}return true}function cr(e,t,r){if(e!=null&&t!=null){var n=r[e];if(n===undefined){r[e]=t}else if(Array.isArray(n)){if(Array.isArray(t)){r[e]=n.concat(t)}else{n.push(t)}}else{if(Array.isArray(t)){r[e]=[n].concat(t)}else{r[e]=[n,t]}}}}function hr(t,r,n,e,i){if(e==null||ur(t,e)){return}else{t.push(e)}if(fr(e)){var a=ee(e,"name");var o=e.value;if(e.multiple&&e.tagName==="SELECT"){o=M(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e.files){o=M(e.files)}cr(a,o,r);if(i){vr(e,n)}}if(h(e,"form")){var s=e.elements;oe(s,function(e){hr(t,r,n,e,i)})}}function vr(e,t){if(e.willValidate){ce(e,"htmx:validation:validate");if(!e.checkValidity()){t.push({elt:e,message:e.validationMessage,validity:e.validity});ce(e,"htmx:validation:failed",{message:e.validationMessage,validity:e.validity})}}}function dr(e,t){var r=[];var n={};var i={};var a=[];var o=ae(e);if(o.lastButtonClicked&&!se(o.lastButtonClicked)){o.lastButtonClicked=null}var s=h(e,"form")&&e.noValidate!==true||te(e,"hx-validate")==="true";if(o.lastButtonClicked){s=s&&o.lastButtonClicked.formNoValidate!==true}if(t!=="get"){hr(r,i,a,v(e,"form"),s)}hr(r,n,a,e,s);if(o.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){var l=o.lastButtonClicked||e;var u=ee(l,"name");cr(u,l.value,i)}var f=me(e,"hx-include");oe(f,function(e){hr(r,n,a,e,s);if(!h(e,"form")){oe(e.querySelectorAll(rt),function(e){hr(r,n,a,e,s)})}});n=le(n,i);return{errors:a,values:n}}function gr(e,t,r){if(e!==""){e+="&"}if(String(r)==="[object Object]"){r=JSON.stringify(r)}var n=encodeURIComponent(r);e+=encodeURIComponent(t)+"="+n;return e}function pr(e){var t="";for(var r in e){if(e.hasOwnProperty(r)){var n=e[r];if(Array.isArray(n)){oe(n,function(e){t=gr(t,r,e)})}else{t=gr(t,r,n)}}}return t}function mr(e){var t=new FormData;for(var r in e){if(e.hasOwnProperty(r)){var n=e[r];if(Array.isArray(n)){oe(n,function(e){t.append(r,e)})}else{t.append(r,n)}}}return t}function xr(e,t,r){var n={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":re().location.href};Rr(e,"hx-headers",false,n);if(r!==undefined){n["HX-Prompt"]=r}if(ae(e).boosted){n["HX-Boosted"]="true"}return n}function yr(t,e){var r=ne(e,"hx-params");if(r){if(r==="none"){return{}}else if(r==="*"){return t}else if(r.indexOf("not ")===0){oe(r.substr(4).split(","),function(e){e=e.trim();delete t[e]});return t}else{var n={};oe(r.split(","),function(e){e=e.trim();n[e]=t[e]});return n}}else{return t}}function br(e){return ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a<i.length;a++){var o=i[a];if(o.indexOf("swap:")===0){n["swapDelay"]=d(o.substr(5))}else if(o.indexOf("settle:")===0){n["settleDelay"]=d(o.substr(7))}else if(o.indexOf("transition:")===0){n["transition"]=o.substr(11)==="true"}else if(o.indexOf("ignoreTitle:")===0){n["ignoreTitle"]=o.substr(12)==="true"}else if(o.indexOf("scroll:")===0){var s=o.substr(7);var l=s.split(":");var u=l.pop();var f=l.length>0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","<style>                      ."+Q.config.indicatorClass+"{opacity:0}                      ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;}                      ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;}                    </style>")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()});
\ No newline at end of file
diff --git a/tests/support/seed.cpp b/tests/support/seed.cpp
new file mode 100644
index 0000000..09dc279
--- /dev/null
+++ b/tests/support/seed.cpp
@@ -0,0 +1,81 @@
+#include "seed.hpp"
+
+#include <cstddef>
+#include <cstdint>
+#include <format>
+#include <span>
+#include <string>
+#include <vector>
+
+#include <Magick++.h>
+
+namespace overseer::test
+{
+
+namespace
+{
+
+namespace inv = ::overseer::inventory;
+
+// Build a 4x4 solid-color PNG so `InventoryRepo::putAttachment` has
+// real bytes to run through ImageMagick.
+std::vector<unsigned char> tinyPng(const std::string& color)
+{
+    Magick::Image img(Magick::Geometry(4, 4), Magick::Color(color));
+    img.magick("PNG");
+    Magick::Blob blob;
+    img.write(&blob);
+    const auto* p = static_cast<const unsigned char*>(blob.data());
+    return std::vector<unsigned char>(p, p + blob.length());
+}
+
+} // namespace
+
+SeededFamily seedFamily(inv::InventoryRepo& repo)
+{
+    SeededFamily out;
+
+    inv::Storage apt;
+    apt.name = "Apartment";
+    out.apartment_id = *repo.createStorage(apt);
+
+    inv::Storage room;
+    room.name = "Room";
+    room.parent_id = out.apartment_id;
+    out.room_id = *repo.createStorage(room);
+
+    inv::Storage shelf;
+    shelf.name = "Shelf";
+    shelf.parent_id = out.room_id;
+    out.shelf_id = *repo.createStorage(shelf);
+
+    // 5 attachments.
+    const std::array<const char*, 5> colors{
+        "red", "green", "blue", "yellow", "gray"};
+    for(size_t i = 0; i < colors.size(); ++i)
+    {
+        auto bytes = tinyPng(colors[i]);
+        std::span<const unsigned char> sp(bytes);
+        out.attachment_ids[i] = *repo.putAttachment(sp, "image/png");
+    }
+
+    // 20 stuffs spread across the three storages. First five get an
+    // attachment.
+    const std::array<int64_t, 3> storage_cycle{
+        out.apartment_id, out.room_id, out.shelf_id};
+    out.stuff_ids.reserve(20);
+    for(int i = 0; i < 20; ++i)
+    {
+        inv::Stuff s;
+        s.name = std::format("Stuff {:02d}", i);
+        s.storage_id = storage_cycle[static_cast<size_t>(i) % 3];
+        if(i < 5)
+        {
+            s.attachment_id = out.attachment_ids[static_cast<size_t>(i)];
+        }
+        out.stuff_ids.push_back(*repo.createStuff(s));
+    }
+    return out;
+}
+
+} // namespace overseer::test
diff --git a/tests/support/seed.hpp b/tests/support/seed.hpp
new file mode 100644
index 0000000..3fda76c
--- /dev/null
+++ b/tests/support/seed.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <array>
+#include <cstdint>
+#include <vector>
+
+#include "inventory/repo.hpp"
+
+namespace overseer::test
+{
+
+struct SeededFamily
+{
+    // Apartment is the root storage.
+    int64_t apartment_id = 0;
+    // Room is a child of Apartment.
+    int64_t room_id = 0;
+    // Shelf is a child of Room.
+    int64_t shelf_id = 0;
+    // 20 stuffs distributed across the three storages.
+    std::vector<int64_t> stuff_ids;
+    // 5 attachment ids (small distinct blobs, all assigned to the
+    // first 5 stuffs to keep test fixtures wiring simple).
+    std::array<int64_t, 5> attachment_ids{};
+};
+
+/// Seed a 3-level tree (Apartment > Room > Shelf), 20 stuffs, and 5
+/// attachments per design §14.3. Pre-condition: the DB has had the
+/// schema applied. Returns the ids of the created rows so individual
+/// tests can address them.
+SeededFamily seedFamily(::overseer::inventory::InventoryRepo& repo);
+
+} // namespace overseer::test
diff --git a/tests/test_http.cpp b/tests/test_http.cpp
new file mode 100644
index 0000000..d7296fe
--- /dev/null
+++ b/tests/test_http.cpp
@@ -0,0 +1,246 @@
+// HTTP integration tests. Spins a `Server` on 127.0.0.1 against an
+// AuthMock and drives requests with httplib::Client.
+#include <cerrno>
+#include <chrono>
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <gtest/gtest.h>
+#include <httplib.h>
+
+#include <mw/auth.hpp>
+#include <mw/auth_mock.hpp>
+#include <mw/database.hpp>
+
+#include "db/migrations.hpp"
+#include "overseer/config.hpp"
+#include "overseer/modules/inventory/routes.hpp"
+#include "overseer/server.hpp"
+#include "overseer/session.hpp"
+#include "support/seed.hpp"
+
+namespace
+{
+
+// Find an OS-assigned free TCP port, then close the probe socket. The
+// returned port is racy but works fine for single-process test runs.
+int findFreePort()
+{
+    int s = ::socket(AF_INET, SOCK_STREAM, 0);
+    if(s < 0) return 0;
+    sockaddr_in addr{};
+    addr.sin_family = AF_INET;
+    addr.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
+    addr.sin_port = 0;
+    if(::bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0)
+    {
+        ::close(s);
+        return 0;
+    }
+    socklen_t len = sizeof(addr);
+    if(::getsockname(s, reinterpret_cast<sockaddr*>(&addr), &len) != 0)
+    {
+        ::close(s);
+        return 0;
+    }
+    int port = ::ntohs(addr.sin_port);
+    ::close(s);
+    return port;
+}
+
+struct TestServer
+{
+    std::unique_ptr<mw::SQLite> db;
+    std::unique_ptr<overseer::Server> server;
+    std::vector<std::unique_ptr<overseer::Module>> modules;
+    int port = 0;
+
+    // Drives the call once we know the port the listener bound to.
+    httplib::Client client() { return httplib::Client("127.0.0.1", port); }
+};
+
+std::unique_ptr<TestServer> startServer()
+{
+    auto ts = std::make_unique<TestServer>();
+
+    auto db_r = mw::SQLite::connectMemory();
+    EXPECT_TRUE(db_r.has_value());
+    ts->db = std::move(*db_r);
+    EXPECT_TRUE(overseer::db::migrateIfNeeded(*ts->db, "").has_value());
+
+    ts->port = findFreePort();
+    EXPECT_NE(ts->port, 0);
+
+    overseer::Config cfg;
+    cfg.bind_address = "127.0.0.1";
+    cfg.port = ts->port;
+    cfg.oidc.redirect_uri = "http://127.0.0.1/oidc/callback";
+    // Empty issuer_url makes AuthHandler skip OIDC discovery.
+    cfg.oidc.issuer_url = "";
+
+    auto auth = std::make_unique<::testing::NiceMock<mw::AuthMock>>();
+
+    ts->server = std::make_unique<overseer::Server>(
+        cfg, *ts->db,
+        OVERSEER_TEST_STATIC_DIR,
+        OVERSEER_TEST_TEMPLATE_DIR,
+        /*dev_mode=*/true,
+        std::move(auth));
+
+    ts->modules.emplace_back(
+        overseer::modules::inventory_module::create());
+    ts->server->setModules(std::move(ts->modules));
+
+    EXPECT_TRUE(ts->server->start().has_value());
+    // Give the listener a brief moment to accept connections.
+    std::this_thread::sleep_for(std::chrono::milliseconds(50));
+    return ts;
+}
+
+void stopServer(TestServer& ts)
+{
+    ts.server->stop();
+    ts.server->wait();
+}
+
+// Authenticate a fake session and return (cookie, csrf_token).
+struct FakeSession
+{
+    std::string sid;
+    std::string csrf;
+};
+
+FakeSession installFakeSession(TestServer& ts,
+                               const std::string& uid = "tester",
+                               const std::string& name = "Tester")
+{
+    mw::UserInfo u; u.id = uid; u.name = name;
+    mw::Tokens t;
+    auto sid = ts.server->auth().sessions().create(std::move(u),
+                                                   std::move(t));
+    auto sess = ts.server->auth().sessions().get(sid);
+    return {sid, sess->csrf_token};
+}
+
+} // namespace
+
+TEST(HttpServer, HealthzReturns204)
+{
+    auto ts = startServer();
+    auto cli = ts->client();
+    auto resp = cli.Get("/healthz");
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 204);
+    stopServer(*ts);
+}
+
+TEST(HttpServer, RootRedirectsToInventory)
+{
+    auto ts = startServer();
+    auto cli = ts->client();
+    auto resp = cli.Get("/");
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 302);
+    EXPECT_EQ(resp->get_header_value("Location"), "/inventory");
+    stopServer(*ts);
+}
+
+TEST(HttpServer, TrailingSlashRedirects)
+{
+    auto ts = startServer();
+    auto cli = ts->client();
+    auto resp = cli.Get("/inventory/");
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 301);
+    EXPECT_EQ(resp->get_header_value("Location"), "/inventory");
+    stopServer(*ts);
+}
+
+TEST(HttpServer, UnauthedInventoryRedirectsToLogin)
+{
+    auto ts = startServer();
+    auto cli = ts->client();
+    auto resp = cli.Get("/inventory");
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 302);
+    const auto loc = resp->get_header_value("Location");
+    EXPECT_NE(loc.find("/oidc/login"), std::string::npos);
+    stopServer(*ts);
+}
+
+TEST(HttpServer, AuthedInventoryRendersListPage)
+{
+    auto ts = startServer();
+    overseer::inventory::InventoryRepo repo(*ts->db);
+    overseer::test::seedFamily(repo);
+    auto sess = installFakeSession(*ts);
+
+    auto cli = ts->client();
+    httplib::Headers h{{"Cookie", "overseer_sid=" + sess.sid}};
+    auto resp = cli.Get("/inventory", h);
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 200);
+    // The page lists the root storages; "Apartment" should appear.
+    EXPECT_NE(resp->body.find("Apartment"), std::string::npos);
+    stopServer(*ts);
+}
+
+TEST(HttpServer, CsrfRejectedPost)
+{
+    auto ts = startServer();
+    overseer::inventory::InventoryRepo repo(*ts->db);
+    overseer::test::seedFamily(repo);
+    auto sess = installFakeSession(*ts);
+
+    auto cli = ts->client();
+    httplib::Headers h{
+        {"Cookie", "overseer_sid=" + sess.sid},
+        {"Origin", "http://127.0.0.1"},
+    };
+    // Wrong csrf_token in the form body.
+    auto resp = cli.Post("/inventory/storage", h,
+                         "name=NewStorage&csrf_token=wrong",
+                         "application/x-www-form-urlencoded");
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 403);
+    stopServer(*ts);
+}
+
+TEST(HttpServer, StuffNameInlineUpdateReturnsFragment)
+{
+    auto ts = startServer();
+    overseer::inventory::InventoryRepo repo(*ts->db);
+    auto seeded = overseer::test::seedFamily(repo);
+    auto sess = installFakeSession(*ts);
+
+    auto cli = ts->client();
+    httplib::Headers h{
+        {"Cookie", "overseer_sid=" + sess.sid},
+        {"Origin", "http://127.0.0.1"},
+        {"HX-Request", "true"},
+    };
+    const std::string body =
+        "name=Renamed&csrf_token=" + sess.csrf;
+    auto resp = cli.Put(
+        "/inventory/stuff/" + std::to_string(seeded.stuff_ids.front())
+            + "/name",
+        h, body, "application/x-www-form-urlencoded");
+    ASSERT_TRUE(resp);
+    EXPECT_EQ(resp->status, 200);
+    // The fragment is an editable h1 containing the new name.
+    EXPECT_NE(resp->body.find("Renamed"), std::string::npos);
+
+    auto updated = repo.getStuff(seeded.stuff_ids.front());
+    ASSERT_TRUE(updated.has_value());
+    EXPECT_EQ(updated->name, "Renamed");
+    stopServer(*ts);
+}
diff --git a/tests/test_inventory.cpp b/tests/test_inventory.cpp
new file mode 100644
index 0000000..d08f502
--- /dev/null
+++ b/tests/test_inventory.cpp
@@ -0,0 +1,460 @@
+// Unit tests for the inventory library. Run only when
+// -DOVERSEER_BUILD_TEST=ON.
+#include <array>
+#include <cstdint>
+#include <memory>
+#include <span>
+#include <string>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+#include "db/migrations.hpp"
+#include "inventory/repo.hpp"
+#include "support/seed.hpp"
+
+namespace inv = overseer::inventory;
+
+namespace
+{
+
+std::unique_ptr<mw::SQLite> makeMemDB()
+{
+    auto db = mw::SQLite::connectMemory();
+    EXPECT_TRUE(db.has_value());
+    auto& d = **db;
+    EXPECT_TRUE(overseer::db::migrateDB0To1(d).has_value());
+    return std::move(*db);
+}
+
+std::string errMsg(const mw::Error& e)
+{
+    return mw::errorMsg(e);
+}
+
+} // namespace
+
+// ---- storage ---------------------------------------------------------
+
+TEST(InventoryRepo, CreateAndGetStorage)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s;
+    s.name = "Living Room";
+    auto id_r = repo.createStorage(s);
+    ASSERT_TRUE(id_r.has_value());
+    auto got_r = repo.getStorage(*id_r);
+    ASSERT_TRUE(got_r.has_value());
+    EXPECT_EQ(got_r->name, "Living Room");
+    EXPECT_FALSE(got_r->parent_id.has_value());
+}
+
+TEST(InventoryRepo, CreateStorageEmptyNameIsBadInput)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.createStorage(inv::Storage{});
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
+}
+
+TEST(InventoryRepo, GetUnknownStorageReturnsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.getStorage(9999);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
+
+TEST(InventoryRepo, SiblingNameCollisionIsTaken)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage parent;
+    parent.name = "Apartment";
+    auto pid = *repo.createStorage(parent);
+
+    inv::Storage a, dup;
+    a.name = "Room"; a.parent_id = pid;
+    dup.name = "Room"; dup.parent_id = pid;
+    ASSERT_TRUE(repo.createStorage(a).has_value());
+    auto r = repo.createStorage(dup);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NAME_TAKEN);
+}
+
+TEST(InventoryRepo, RootNameCollisionIsTaken)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage a, dup;
+    a.name = "Root"; dup.name = "Root";
+    ASSERT_TRUE(repo.createStorage(a).has_value());
+    auto r = repo.createStorage(dup);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NAME_TAKEN);
+}
+
+TEST(InventoryRepo, ListChildrenRootsAndNested)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto seeded = overseer::test::seedFamily(repo);
+
+    auto roots = *repo.listChildren(std::nullopt);
+    ASSERT_EQ(roots.size(), 1U);
+    EXPECT_EQ(roots[0].id, seeded.apartment_id);
+
+    auto kids = *repo.listChildren(seeded.apartment_id);
+    ASSERT_EQ(kids.size(), 1U);
+    EXPECT_EQ(kids[0].id, seeded.room_id);
+}
+
+TEST(InventoryRepo, PathToBuildsRootFirst)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto seeded = overseer::test::seedFamily(repo);
+
+    auto path = *repo.pathTo(seeded.shelf_id);
+    ASSERT_EQ(path.size(), 3U);
+    EXPECT_EQ(path[0].id, seeded.apartment_id);
+    EXPECT_EQ(path[1].id, seeded.room_id);
+    EXPECT_EQ(path[2].id, seeded.shelf_id);
+}
+
+TEST(InventoryRepo, PathToUnknownIsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.pathTo(7777);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
+
+TEST(InventoryRepo, UpdateStorageChangesName)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s;
+    s.name = "Old";
+    auto id = *repo.createStorage(s);
+    s.id = id;
+    s.name = "New";
+    ASSERT_TRUE(repo.updateStorage(s).has_value());
+    EXPECT_EQ(repo.getStorage(id)->name, "New");
+}
+
+TEST(InventoryRepo, UpdateUnknownStorageIsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s;
+    s.id = 12345;
+    s.name = "Phantom";
+    auto r = repo.updateStorage(s);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
+
+TEST(InventoryRepo, UpdateStorageEmptyNameIsBadInput)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s;
+    s.name = "Old";
+    auto id = *repo.createStorage(s);
+    s.id = id;
+    s.name = "";
+    auto r = repo.updateStorage(s);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
+}
+
+TEST(InventoryRepo, DeleteEmptyStorage)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s; s.name = "Empty";
+    auto id = *repo.createStorage(s);
+    ASSERT_TRUE(repo.deleteStorage(id).has_value());
+    EXPECT_FALSE(repo.getStorage(id).has_value());
+}
+
+TEST(InventoryRepo, DeleteStorageWithChildIsNotEmpty)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage parent;  parent.name = "P";
+    auto pid = *repo.createStorage(parent);
+    inv::Storage child;   child.name = "C"; child.parent_id = pid;
+    ASSERT_TRUE(repo.createStorage(child).has_value());
+
+    auto r = repo.deleteStorage(pid);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_EMPTY);
+}
+
+TEST(InventoryRepo, DeleteStorageWithStuffIsNotEmpty)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s; s.name = "S";
+    auto sid = *repo.createStorage(s);
+    inv::Stuff t; t.name = "Thing"; t.storage_id = sid;
+    ASSERT_TRUE(repo.createStuff(t).has_value());
+
+    auto r = repo.deleteStorage(sid);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_EMPTY);
+}
+
+TEST(InventoryRepo, DeleteUnknownStorageIsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.deleteStorage(999);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
+
+// ---- stuff -----------------------------------------------------------
+
+TEST(InventoryRepo, CreateStuffRequiresStorage)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Stuff t; t.name = "Thing"; // no storage_id
+    auto r = repo.createStuff(t);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
+}
+
+TEST(InventoryRepo, CreateStuffRequiresName)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s; s.name = "X";
+    auto sid = *repo.createStorage(s);
+    inv::Stuff t; t.storage_id = sid;
+    auto r = repo.createStuff(t);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
+}
+
+TEST(InventoryRepo, GetUnknownStuffIsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.getStuff(42);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
+
+TEST(InventoryRepo, ListInStorage)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto seeded = overseer::test::seedFamily(repo);
+
+    // 20 stuffs cycle across 3 storages -> apartment gets 7, room 7,
+    // shelf 6.
+    auto in_apt = *repo.listInStorage(seeded.apartment_id);
+    auto in_room = *repo.listInStorage(seeded.room_id);
+    auto in_shelf = *repo.listInStorage(seeded.shelf_id);
+    EXPECT_EQ(in_apt.size() + in_room.size() + in_shelf.size(), 20U);
+    EXPECT_EQ(in_apt.size(), 7U);
+    EXPECT_EQ(in_room.size(), 7U);
+    EXPECT_EQ(in_shelf.size(), 6U);
+}
+
+TEST(InventoryRepo, UpdateStuffRenamesAndMovesAtomically)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage a, b; a.name = "A"; b.name = "B";
+    auto aid = *repo.createStorage(a);
+    auto bid = *repo.createStorage(b);
+    inv::Stuff t; t.name = "thing"; t.storage_id = aid;
+    auto tid = *repo.createStuff(t);
+
+    inv::Stuff up;
+    up.id = tid; up.name = "thing2"; up.storage_id = bid;
+    ASSERT_TRUE(repo.updateStuff(up).has_value());
+
+    auto got = *repo.getStuff(tid);
+    EXPECT_EQ(got.name, "thing2");
+    EXPECT_EQ(got.storage_id, bid);
+    // The move via updateStuff went through moveStuff, so history
+    // should have a single entry for the previous location.
+    auto moves = *repo.recentMoves(tid);
+    ASSERT_EQ(moves.size(), 1U);
+    EXPECT_EQ(moves.front().storage_id, aid);
+}
+
+TEST(InventoryRepo, UpdateStuffBadInput)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Stuff up; up.id = 1; up.name = ""; up.storage_id = 1;
+    auto r = repo.updateStuff(up);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
+}
+
+TEST(InventoryRepo, MoveStuffRecordsHistory)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage a, b; a.name = "A"; b.name = "B";
+    auto a_id = *repo.createStorage(a);
+    auto b_id = *repo.createStorage(b);
+
+    inv::Stuff t; t.name = "thing"; t.storage_id = a_id;
+    auto t_id = *repo.createStuff(t);
+
+    ASSERT_TRUE(repo.moveStuff(t_id, b_id).has_value());
+    auto moves = *repo.recentMoves(t_id);
+    ASSERT_EQ(moves.size(), 1U);
+    EXPECT_EQ(moves.front().storage_id, a_id);
+    EXPECT_EQ(repo.getStuff(t_id)->storage_id, b_id);
+}
+
+TEST(InventoryRepo, MoveStuffSameStorageIsNoop)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s; s.name = "S";
+    auto sid = *repo.createStorage(s);
+    inv::Stuff t; t.name = "thing"; t.storage_id = sid;
+    auto tid = *repo.createStuff(t);
+    ASSERT_TRUE(repo.moveStuff(tid, sid).has_value());
+    EXPECT_EQ(repo.recentMoves(tid)->size(), 0U);
+}
+
+TEST(InventoryRepo, MoveTrimTriggerKeepsLastTen)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    std::vector<int64_t> storages;
+    for(int i = 0; i < 12; ++i)
+    {
+        inv::Storage s;
+        s.name = std::string("S") + std::to_string(i);
+        storages.push_back(*repo.createStorage(s));
+    }
+    inv::Stuff t; t.name = "thing"; t.storage_id = storages[0];
+    auto tid = *repo.createStuff(t);
+    // 11 moves: from S0->S1, S1->S2, ..., S10->S11. That records 11
+    // entries; the trigger should keep only the most recent 10.
+    for(int i = 1; i <= 11; ++i)
+    {
+        ASSERT_TRUE(repo.moveStuff(tid, storages[static_cast<size_t>(i)])
+                        .has_value());
+    }
+    auto moves = *repo.recentMoves(tid);
+    EXPECT_EQ(moves.size(), 10U);
+    // The most-recent move recorded was the one out of S10. The oldest
+    // (S0) should have been trimmed.
+    EXPECT_EQ(moves.front().storage_id, storages[10]);
+}
+
+TEST(InventoryRepo, DeleteStuff)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    inv::Storage s; s.name = "S";
+    auto sid = *repo.createStorage(s);
+    inv::Stuff t; t.name = "thing"; t.storage_id = sid;
+    auto tid = *repo.createStuff(t);
+    ASSERT_TRUE(repo.deleteStuff(tid).has_value());
+    EXPECT_FALSE(repo.getStuff(tid).has_value());
+}
+
+TEST(InventoryRepo, DeleteUnknownStuffIsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.deleteStuff(99);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
+
+// ---- search ----------------------------------------------------------
+
+TEST(InventoryRepo, SearchStuffByName)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto seeded = overseer::test::seedFamily(repo);
+
+    auto hits = *repo.searchStuffs("Stuff");
+    EXPECT_FALSE(hits.empty());
+    // First seeded stuff is named "Stuff 00", which matches the prefix.
+    bool found = false;
+    for(const auto& s : hits)
+    {
+        if(s.id == seeded.stuff_ids.front()) { found = true; break; }
+    }
+    EXPECT_TRUE(found);
+}
+
+TEST(InventoryRepo, SearchStorageFindsApartment)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto seeded = overseer::test::seedFamily(repo);
+    auto hits = *repo.searchStorages("Apartment");
+    bool found = false;
+    for(const auto& s : hits)
+    {
+        if(s.id == seeded.apartment_id) { found = true; break; }
+    }
+    EXPECT_TRUE(found);
+}
+
+TEST(InventoryRepo, SearchEmptyQueryReturnsEmpty)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    EXPECT_TRUE(repo.searchStorages("")->empty());
+    EXPECT_TRUE(repo.searchStuffs("")->empty());
+}
+
+// ---- attachments -----------------------------------------------------
+
+TEST(InventoryRepo, PutAndGetAttachmentRoundTrip)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto seeded = overseer::test::seedFamily(repo);
+    // seedFamily inserted 5 attachments; pull the first one.
+    auto a = repo.getAttachment(seeded.attachment_ids[0]);
+    ASSERT_TRUE(a.has_value());
+    EXPECT_EQ(a->mime, "image/avif");
+    EXPECT_FALSE(a->bytes.empty());
+}
+
+TEST(InventoryRepo, PutEmptyAttachmentIsBadInput)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    std::span<const unsigned char> empty;
+    auto r = repo.putAttachment(empty, "image/png");
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
+}
+
+TEST(InventoryRepo, GetUnknownAttachmentIsNotFound)
+{
+    auto db = makeMemDB();
+    inv::InventoryRepo repo(*db);
+    auto r = repo.getAttachment(123);
+    ASSERT_FALSE(r.has_value());
+    EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
+}
diff --git a/tests/test_migrations.cpp b/tests/test_migrations.cpp
new file mode 100644
index 0000000..2dad17a
--- /dev/null
+++ b/tests/test_migrations.cpp
@@ -0,0 +1,75 @@
+#include <gtest/gtest.h>
+
+#include <mw/database.hpp>
+
+#include "db/migrations.hpp"
+
+namespace
+{
+
+bool tableExists(mw::SQLite& db, const std::string& name)
+{
+    auto stmt = db.statementFromStr(
+        "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?;");
+    EXPECT_TRUE(stmt.has_value());
+    EXPECT_TRUE(stmt->bind(name).has_value());
+    auto rows = db.eval<int64_t>(std::move(*stmt));
+    EXPECT_TRUE(rows.has_value());
+    return !rows->empty();
+}
+
+int64_t userVersion(mw::SQLite& db)
+{
+    auto v = db.evalToValue<int64_t>("PRAGMA user_version;");
+    EXPECT_TRUE(v.has_value());
+    return *v;
+}
+
+} // namespace
+
+TEST(Migrations, V0ToV1AppliesSchema)
+{
+    auto db_r = mw::SQLite::connectMemory();
+    ASSERT_TRUE(db_r.has_value());
+    auto& db = **db_r;
+    ASSERT_TRUE(overseer::db::migrateDB0To1(db).has_value());
+
+    // The init migration creates a known set of tables.
+    EXPECT_TRUE(tableExists(db, "storage"));
+    EXPECT_TRUE(tableExists(db, "stuff"));
+    EXPECT_TRUE(tableExists(db, "stuff_move"));
+    EXPECT_TRUE(tableExists(db, "attachment"));
+}
+
+TEST(Migrations, MigrateIfNeededSetsUserVersion)
+{
+    auto db_r = mw::SQLite::connectMemory();
+    ASSERT_TRUE(db_r.has_value());
+    auto& db = **db_r;
+    // Empty path -> skip backup, which is what we want in-memory.
+    ASSERT_TRUE(overseer::db::migrateIfNeeded(db, "").has_value());
+    EXPECT_EQ(userVersion(db), overseer::db::HIGHEST_KNOWN_VERSION);
+}
+
+TEST(Migrations, MigrateIfNeededIsIdempotent)
+{
+    auto db_r = mw::SQLite::connectMemory();
+    ASSERT_TRUE(db_r.has_value());
+    auto& db = **db_r;
+    ASSERT_TRUE(overseer::db::migrateIfNeeded(db, "").has_value());
+    const auto v1 = userVersion(db);
+    // Running it again is a no-op: should succeed and the version
+    // should not move.
+    ASSERT_TRUE(overseer::db::migrateIfNeeded(db, "").has_value());
+    EXPECT_EQ(userVersion(db), v1);
+}
+
+TEST(Migrations, RefusesNewerThanBinary)
+{
+    auto db_r = mw::SQLite::connectMemory();
+    ASSERT_TRUE(db_r.has_value());
+    auto& db = **db_r;
+    ASSERT_TRUE(db.execute("PRAGMA user_version = 9999;").has_value());
+    auto r = overseer::db::migrateIfNeeded(db, "");
+    ASSERT_FALSE(r.has_value());
+}