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());
+}