BareGit

Implement MVP accounts and collection loop

- Add passwordless authentication, sessions, Unicode usernames, and role
  authorization.
- Add the version-one account schema, administrator bootstrap, and secure
  email transports.
- Add atomic daily pulls, weighted probabilities, quantities, and creator
  ownership.
- Replace public prototype routes with protected Inja flows and expanded
  service, persistence, and HTTP tests.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Aug 23 21:44:15 2026 -0700
Commit: 7aedab034197d187a20fa1aec929ba1de5e15f24

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0623c28..4c29ae9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,10 +18,18 @@ add_executable(
     card_collection
     src/app.cpp
     src/asset_store.cpp
+    src/authentication.cpp
+    src/authorization.cpp
+    src/card_pool.cpp
     src/card_service.cpp
+    src/clock.cpp
+    src/collection.cpp
     src/config.cpp
     src/data.cpp
     src/data_sqlite.cpp
+    src/email_address.cpp
+    src/email_sender_file.cpp
+    src/email_sender_mailjet.cpp
     src/game_registry.cpp
     src/image_processor.cpp
     src/markdown_renderer.cpp
@@ -30,7 +38,10 @@ add_executable(
     src/non_secret_random.cpp
     src/public_id.cpp
     src/series_service.cpp
+    src/secret_token.cpp
     src/startup.cpp
+    src/username.cpp
+    src/user_service.cpp
     src/url_builder.cpp
 )
 
@@ -66,19 +77,26 @@ target_link_libraries(
         ImageMagick::MagickCore
         MacroDown::MacroDown
         mw::http-server
+        mw::crypto
         mw::mw
         mw::sqlite
         mw::url
+        nlohmann_json::nlohmann_json
         pantor::inja
         spdlog::spdlog
         tomlplusplus::tomlplusplus
+        uni-algo::uni-algo
 )
 
 if(CARD_COLLECTION_BUILD_TESTS)
     include(CTest)
     enable_testing()
 
-    add_executable(data_mock_test tests/data_mock_test.cpp)
+    add_executable(
+        data_mock_test
+        src/data.cpp
+        tests/data_mock_test.cpp
+    )
     target_compile_features(data_mock_test PRIVATE cxx_std_23)
     set_target_properties(data_mock_test PROPERTIES CXX_EXTENSIONS OFF)
     target_include_directories(
@@ -98,9 +116,140 @@ if(CARD_COLLECTION_BUILD_TESTS)
     include(GoogleTest)
     gtest_discover_tests(data_mock_test)
 
+    add_executable(
+        mvp_primitives_test
+        src/authorization.cpp
+        src/card_pool.cpp
+        src/clock.cpp
+        src/email_address.cpp
+        src/email_sender_file.cpp
+        src/secret_token.cpp
+        src/username.cpp
+        tests/mvp_primitives_test.cpp
+    )
+    target_compile_features(mvp_primitives_test PRIVATE cxx_std_23)
+    set_target_properties(
+        mvp_primitives_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        mvp_primitives_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        mvp_primitives_test
+        PRIVATE
+            GTest::gmock_main
+            mw::crypto
+            mw::mw
+            mw::url
+            uni-algo::uni-algo
+    )
+    gtest_discover_tests(mvp_primitives_test)
+
+    add_executable(
+        user_collection_test
+        src/card_pool.cpp
+        src/clock.cpp
+        src/collection.cpp
+        src/data.cpp
+        src/data_sqlite.cpp
+        src/game_registry.cpp
+        src/startup.cpp
+        src/username.cpp
+        src/user_service.cpp
+        tests/user_collection_test.cpp
+    )
+    target_compile_features(user_collection_test PRIVATE cxx_std_23)
+    set_target_properties(
+        user_collection_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        user_collection_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        user_collection_test
+        PRIVATE
+            GTest::gmock_main
+            mw::crypto
+            mw::mw
+            mw::sqlite
+            spdlog::spdlog
+            uni-algo::uni-algo
+    )
+    gtest_discover_tests(user_collection_test)
+
+    add_executable(
+        authentication_test
+        src/authentication.cpp
+        src/clock.cpp
+        src/data.cpp
+        src/data_sqlite.cpp
+        src/email_address.cpp
+        src/game_registry.cpp
+        src/secret_token.cpp
+        src/startup.cpp
+        tests/authentication_test.cpp
+    )
+    target_compile_features(authentication_test PRIVATE cxx_std_23)
+    set_target_properties(
+        authentication_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        authentication_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        authentication_test
+        PRIVATE
+            GTest::gmock_main
+            mw::crypto
+            mw::mw
+            mw::sqlite
+            mw::url
+            spdlog::spdlog
+    )
+    gtest_discover_tests(authentication_test)
+
+    add_executable(
+        email_sender_mailjet_test
+        src/email_sender_mailjet.cpp
+        tests/email_sender_mailjet_test.cpp
+    )
+    target_compile_features(email_sender_mailjet_test PRIVATE cxx_std_23)
+    set_target_properties(
+        email_sender_mailjet_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        email_sender_mailjet_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        email_sender_mailjet_test
+        PRIVATE
+            GTest::gmock_main
+            mw::mw
+            mw::url
+            nlohmann_json::nlohmann_json
+    )
+    gtest_discover_tests(email_sender_mailjet_test)
+
     add_executable(
         config_test
         src/config.cpp
+        src/email_address.cpp
         src/non_secret_random.cpp
         tests/config_test.cpp
     )
@@ -120,6 +269,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
             mw::mw
             mw::url
             tomlplusplus::tomlplusplus
+            uni-algo::uni-algo
     )
     gtest_discover_tests(config_test)
 
@@ -153,7 +303,16 @@ if(CARD_COLLECTION_BUILD_TESTS)
         app_test
         src/app.cpp
         src/asset_store.cpp
+        src/authentication.cpp
+        src/authorization.cpp
+        src/card_pool.cpp
         src/card_service.cpp
+        src/clock.cpp
+        src/collection.cpp
+        src/data.cpp
+        src/email_address.cpp
+        src/email_sender_file.cpp
+        src/email_sender_mailjet.cpp
         src/game_registry.cpp
         src/non_secret_random.cpp
         src/data_fake.cpp
@@ -162,6 +321,9 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/multipart_reader.cpp
         src/public_id.cpp
         src/series_service.cpp
+        src/secret_token.cpp
+        src/username.cpp
+        src/user_service.cpp
         src/url_builder.cpp
         tests/app_test.cpp
     )
@@ -182,10 +344,14 @@ if(CARD_COLLECTION_BUILD_TESTS)
             ImageMagick::MagickCore
             MacroDown::MacroDown
             mw::http-server
+            mw::crypto
+            mw::mw
             mw::sqlite
             mw::url
+            nlohmann_json::nlohmann_json
             pantor::inja
             spdlog::spdlog
+            uni-algo::uni-algo
     )
     target_compile_definitions(
         app_test
@@ -198,9 +364,17 @@ if(CARD_COLLECTION_BUILD_TESTS)
         app_integration_test
         src/app.cpp
         src/asset_store.cpp
+        src/authentication.cpp
+        src/authorization.cpp
+        src/card_pool.cpp
         src/card_service.cpp
+        src/clock.cpp
+        src/collection.cpp
         src/data.cpp
         src/data_sqlite.cpp
+        src/email_address.cpp
+        src/email_sender_file.cpp
+        src/email_sender_mailjet.cpp
         src/game_registry.cpp
         src/image_processor.cpp
         src/markdown_renderer.cpp
@@ -208,7 +382,10 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/non_secret_random.cpp
         src/public_id.cpp
         src/series_service.cpp
+        src/secret_token.cpp
         src/startup.cpp
+        src/username.cpp
+        src/user_service.cpp
         src/url_builder.cpp
         tests/app_integration_test.cpp
     )
@@ -237,16 +414,20 @@ if(CARD_COLLECTION_BUILD_TESTS)
             ImageMagick::MagickCore
             MacroDown::MacroDown
             mw::http-server
+            mw::crypto
             mw::mw
             mw::sqlite
             mw::url
+            nlohmann_json::nlohmann_json
             pantor::inja
             spdlog::spdlog
+            uni-algo::uni-algo
     )
     gtest_discover_tests(app_integration_test)
 
     add_executable(
         data_fake_test
+        src/data.cpp
         src/data_fake.cpp
         tests/data_fake_test.cpp
     )
@@ -295,6 +476,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
     add_executable(
         card_service_test
         src/asset_store.cpp
+        src/authorization.cpp
         src/card_service.cpp
         src/data.cpp
         src/data_sqlite.cpp
@@ -304,6 +486,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/multipart_reader.cpp
         src/non_secret_random.cpp
         src/public_id.cpp
+        src/series_service.cpp
         src/startup.cpp
         tests/card_service_test.cpp
     )
diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake
index 3483b88..f6d4958 100644
--- a/cmake/dependencies.cmake
+++ b/cmake/dependencies.cmake
@@ -62,7 +62,7 @@ set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
-set(LIBMW_BUILD_CRYPTO OFF CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_CRYPTO ON CACHE BOOL "" FORCE)
 
 set(INJA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
 set(INJA_EXPORT OFF CACHE BOOL "" FORCE)
@@ -76,6 +76,8 @@ set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE)
 
 FetchContent_MakeAvailable(
     spdlog
+    json
+    uni-algo
     libmw
     macrodown
     tomlplusplus
diff --git a/config.example.toml b/config.example.toml
index 6a46f20..91739ec 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -6,3 +6,21 @@ database_path = "var/card_collection.sqlite3"
 card_storage_root = "var/cards"
 avif_quality = 75
 thumbnail_long_side = 256
+
+# Immutable after the first database initialization.
+administrator_email = "admin@example.com"
+
+# Lowering this value can permanently discard accrued availability.
+maximum_accumulated_pulls = 3
+
+[email]
+transport = "file"
+link_file = "/tmp/card-collection-authentication-link.txt"
+
+# Production Mailjet alternative (replace the file settings above):
+# transport = "mailjet"
+# from_address = "cards@example.com"
+# from_name = "Card Collection"
+# mailjet_api_key_environment = "CARD_COLLECTION_MAILJET_API_KEY"
+# mailjet_secret_key_environment = "CARD_COLLECTION_MAILJET_SECRET_KEY"
+# daily_attempt_limit = 180
diff --git a/src/app.cpp b/src/app.cpp
index 2382566..8fec6fd 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -4,6 +4,7 @@
 #include <charconv>
 #include <cctype>
 #include <cstddef>
+#include <cstdlib>
 #include <filesystem>
 #include <functional>
 #include <memory>
@@ -16,11 +17,15 @@
 #include <vector>
 
 #include <spdlog/spdlog.h>
+#include <mw/crypto.hpp>
 #include <mw/utils.hpp>
 
+#include "email_sender_file.h"
+#include "email_sender_mailjet.h"
 #include "public_id.h"
 #include "multipart_reader.h"
 #include "game_definition.h"
+#include "secret_token.h"
 
 namespace
 {
@@ -49,6 +54,81 @@ struct HtmlSubstitution
     RenderedHtml html;
 };
 
+std::unique_ptr<EmailSenderInterface> makeEmailSender(const Config& config)
+{
+    if(config.email.transport == EmailTransport::FILE)
+    {
+        std::filesystem::path target = config.email.link_file;
+        if(target.empty())
+        {
+            target = config.database_path.parent_path() /
+                "latest-authentication-link.txt";
+        }
+        return std::make_unique<FileEmailSender>(std::move(target));
+    }
+    const char* api_key = std::getenv(
+        config.email.mailjet_api_key_environment.c_str());
+    const char* secret_key = std::getenv(
+        config.email.mailjet_secret_key_environment.c_str());
+    if(api_key == nullptr || *api_key == '\0' ||
+       secret_key == nullptr || *secret_key == '\0')
+    {
+        throw std::runtime_error(
+            "Mailjet credential environment variables are missing");
+    }
+    auto sender = std::make_unique<MailjetEmailSender>(
+        std::make_unique<mw::HTTPSession>(),
+        config.email.from_address,
+        config.email.from_name,
+        api_key,
+        secret_key);
+    auto configured = sender->configure();
+    if(!configured)
+    {
+        throw std::runtime_error(
+            "Failed to configure Mailjet transport: " +
+            configured.error().msg());
+    }
+    return sender;
+}
+
+std::optional<std::string> cookieValue(
+    const App::Request& request, std::string_view name)
+{
+    std::optional<std::string> result;
+    const std::size_t header_count = request.get_header_value_count("Cookie");
+    for(std::size_t header_index = 0;
+        header_index < header_count;
+        ++header_index)
+    {
+        const std::string header_value = request.get_header_value(
+            "Cookie", nullptr, header_index);
+        std::string_view header = header_value;
+        while(!header.empty())
+        {
+            const std::size_t separator = header.find(';');
+            std::string_view item = header.substr(0, separator);
+            item = mw::strip(item);
+            const std::size_t equals = item.find('=');
+            if(equals != std::string_view::npos &&
+               item.substr(0, equals) == name)
+            {
+                if(result)
+                {
+                    return std::nullopt;
+                }
+                result = std::string(item.substr(equals + 1));
+            }
+            if(separator == std::string_view::npos)
+            {
+                break;
+            }
+            header.remove_prefix(separator + 1);
+        }
+    }
+    return result;
+}
+
 RouteSegment literal(std::string value)
 {
     return {RouteSegmentKind::LITERAL, std::move(value)};
@@ -60,7 +140,41 @@ RouteSegment placeholder(std::string value)
 }
 
 const std::unordered_map<std::string, RouteDefinition> ROUTES = {
-    {"card-index", {RouteKind::DYNAMIC, {}}},
+    {"welcome", {RouteKind::DYNAMIC, {}}},
+    {"authentication",
+     {RouteKind::DYNAMIC, {literal("authentication")}}},
+    {"authentication-email",
+     {RouteKind::DYNAMIC,
+      {literal("authentication"), literal("email")}}},
+    {"authentication-sent",
+     {RouteKind::DYNAMIC,
+      {literal("authentication"), literal("sent")}}},
+    {"authentication-confirm",
+     {RouteKind::DYNAMIC,
+      {literal("authentication"), literal("confirm"), placeholder("token")}}},
+    {"logout", {RouteKind::DYNAMIC, {literal("logout")}}},
+    {"onboarding",
+     {RouteKind::DYNAMIC,
+      {literal("onboarding"), literal("username")}}},
+    {"account", {RouteKind::DYNAMIC, {literal("account")}}},
+    {"account-username",
+     {RouteKind::DYNAMIC,
+      {literal("account"), literal("username")}}},
+    {"collection", {RouteKind::DYNAMIC, {literal("collection")}}},
+    {"collection-pull",
+     {RouteKind::DYNAMIC,
+      {literal("collection"), literal("pull")}}},
+    {"creator-cards",
+     {RouteKind::DYNAMIC,
+      {literal("creator"), literal("cards")}}},
+    {"card-index",
+     {RouteKind::DYNAMIC, {literal("admin"), literal("cards")}}},
+    {"admin-users",
+     {RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
+    {"admin-promote",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("users"), placeholder("integer"),
+       literal("promote")}}},
     {"card-new", {RouteKind::DYNAMIC, {literal("cards"), literal("new")}}},
     {"cards", {RouteKind::DYNAMIC, {literal("cards")}}},
     {"card", {RouteKind::DYNAMIC,
@@ -70,19 +184,24 @@ const std::unordered_map<std::string, RouteDefinition> ROUTES = {
     {"card-delete",
      {RouteKind::DYNAMIC,
       {literal("cards"), placeholder("id"), literal("delete")}}},
-    {"series-index", {RouteKind::DYNAMIC, {literal("series")}}},
+    {"series-index",
+     {RouteKind::DYNAMIC, {literal("admin"), literal("series")}}},
     {"series-new",
-     {RouteKind::DYNAMIC, {literal("series"), literal("new")}}},
-    {"series", {RouteKind::DYNAMIC, {literal("series")}}},
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("series"), literal("new")}}},
+    {"series", {RouteKind::DYNAMIC,
+                {literal("admin"), literal("series")}}},
     {"series-edit",
      {RouteKind::DYNAMIC,
-      {literal("series"), placeholder("integer"), literal("edit")}}},
+      {literal("admin"), literal("series"), placeholder("integer"),
+       literal("edit")}}},
     {"series-item",
      {RouteKind::DYNAMIC,
-      {literal("series"), placeholder("integer")}}},
+      {literal("admin"), literal("series"), placeholder("integer")}}},
     {"series-delete",
      {RouteKind::DYNAMIC,
-      {literal("series"), placeholder("integer"), literal("delete")}}},
+      {literal("admin"), literal("series"), placeholder("integer"),
+       literal("delete")}}},
     {"card-asset", {RouteKind::STATIC_MOUNT, {literal("static-cards")}}},
     {"static", {RouteKind::STATIC_MOUNT, {literal("static")}}},
 };
@@ -253,6 +372,39 @@ void respondOperationError(
     respondInternalError(response);
 }
 
+void respondTemplate(
+    inja::Environment& environment,
+    const std::string& filename,
+    const inja::json& data,
+    App::Response& response)
+{
+    try
+    {
+        response.set_content(
+            environment.render_file(filename, data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render {}: {}", filename, error.what());
+        respondInternalError(response);
+    }
+}
+
+std::string roleName(UserRole role)
+{
+    switch(role)
+    {
+    case UserRole::PLAYER:
+        return "Player";
+    case UserRole::CREATOR:
+        return "Creator";
+    case UserRole::ADMINISTRATOR:
+        return "Administrator";
+    }
+    return "Unknown";
+}
+
 std::optional<std::string> optionalText(
     const std::unordered_map<std::string, std::string>& fields,
     const std::string& name)
@@ -411,6 +563,23 @@ App::App(
             "App requires data, games, and a random generator");
     }
 
+    clock_ = std::make_unique<SystemClock>();
+    crypto_ = std::make_unique<mw::Crypto>();
+    email_sender_ = makeEmailSender(config_);
+    authentication_service_ = std::make_unique<AuthenticationService>(
+        *data_source_,
+        *email_sender_,
+        *clock_,
+        *crypto_,
+        config_.base_url,
+        config_.email.daily_attempt_limit);
+    user_service_ = std::make_unique<UserService>(*data_source_);
+    collection_service_ = std::make_unique<CollectionService>(
+        *data_source_,
+        *clock_,
+        *crypto_,
+        config_.maximum_accumulated_pulls);
+
     card_service_ = std::make_unique<CardService>(
         *data_source_,
         *random_,
@@ -459,10 +628,575 @@ App::App(
     series_index_template_ = templates_.parse_template("series_index.html");
 }
 
-void App::handleCardNew(
+void App::setCookie(
+    Response& response,
+    const std::string& name,
+    const std::string& value,
+    std::int64_t maximum_age,
+    bool strict_same_site) const
+{
+    std::string path = config_.base_url.path();
+    if(path.empty())
+    {
+        path = "/";
+    }
+    std::string cookie = name + '=' + value + "; Path=" + path +
+        "; HttpOnly; SameSite=" +
+        (strict_same_site ? "Strict" : "Lax") +
+        "; Max-Age=" + std::to_string(maximum_age);
+    if(config_.base_url.scheme() == "https")
+    {
+        cookie += "; Secure";
+    }
+    response.set_header("Set-Cookie", cookie);
+}
+
+std::optional<App::RequestIdentity> App::requireIdentity(
+    const Request& request,
+    Response& response,
+    bool safe_get,
+    bool allow_onboarding)
+{
+    auto raw_token = cookieValue(request, "card_collection_session");
+    std::optional<SessionContext> session;
+    if(raw_token)
+    {
+        auto loaded = authentication_service_->session(*raw_token);
+        if(!loaded)
+        {
+            spdlog::error("Failed to load session: {}", loaded.error().msg());
+            respondInternalError(response);
+            return std::nullopt;
+        }
+        session = std::move(*loaded);
+    }
+    if(!raw_token || !session)
+    {
+        if(raw_token)
+        {
+            setCookie(
+                response, "card_collection_session", "", 0, false);
+        }
+        response.status = safe_get ? 303 : 401;
+        if(safe_get)
+        {
+            response.set_header("Location", urlFor("authentication"));
+        }
+        else
+        {
+            response.set_content(
+                "Authentication required\n", "text/plain; charset=utf-8");
+        }
+        return std::nullopt;
+    }
+    if(!allow_onboarding && !session->user.username)
+    {
+        response.status = safe_get ? 303 : 409;
+        if(safe_get)
+        {
+            response.set_header("Location", urlFor("onboarding"));
+        }
+        else
+        {
+            response.set_content(
+                "Username onboarding required\n",
+                "text/plain; charset=utf-8");
+        }
+        return std::nullopt;
+    }
+    response.set_header("Cache-Control", "private, no-store");
+    return RequestIdentity{std::move(*session), std::move(*raw_token)};
+}
+
+bool App::verifyCsrf(
+    const Request& request,
+    const RequestIdentity& identity,
+    Response& response) const
+{
+    if(request.get_param_value_count("csrf_token") != 1 ||
+       !constantTimeEqual(
+           request.get_param_value("csrf_token"),
+           identity.session.csrf_token))
+    {
+        response.status = 403;
+        response.set_content("Invalid CSRF token\n", "text/plain");
+        return false;
+    }
+    return true;
+}
+
+void App::handleWelcome(const Request& request, Response& response)
+{
+    auto token = cookieValue(request, "card_collection_session");
+    if(token)
+    {
+        auto session = authentication_service_->session(*token);
+        if(!session)
+        {
+            respondInternalError(response);
+            return;
+        }
+        if(*session)
+        {
+            response.status = 303;
+            response.set_header(
+                "Location",
+                (**session).user.username
+                    ? urlFor("collection")
+                    : urlFor("onboarding"));
+            return;
+        }
+        setCookie(response, "card_collection_session", "", 0, false);
+    }
+    respondTemplate(templates_, "welcome.html", {
+        {"authentication_url", urlFor("authentication")},
+        {"example_url", urlFor("static", {"card_placeholder.svg"})},
+        {"title", "Card Collection"},
+    }, response);
+}
+
+void App::handleAuthentication(
+    [[maybe_unused]] const Request& request,
+    Response& response)
+{
+    auto nonce = generateSecretToken(*crypto_);
+    if(!nonce)
+    {
+        respondInternalError(response);
+        return;
+    }
+    setCookie(
+        response, "card_collection_auth_form", nonce->value, 600, true);
+    response.set_header("Cache-Control", "no-store");
+    respondTemplate(templates_, "authentication.html", {
+        {"action_url", urlFor("authentication-email")},
+        {"form_nonce", nonce->value},
+        {"title", "Sign in · Card Collection"},
+    }, response);
+}
+
+void App::handleAuthenticationEmail(
+    const Request& request, Response& response)
+{
+    const auto nonce = cookieValue(request, "card_collection_auth_form");
+    if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
+       !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
+    {
+        response.status = 403;
+        response.set_content("Invalid form token\n", "text/plain");
+        return;
+    }
+    if(request.get_param_value_count("email") != 1)
+    {
+        respondBadRequest(response, "Email is required");
+        return;
+    }
+    auto sent = authentication_service_->requestEmail(
+        request.get_param_value("email"));
+    if(!sent)
+    {
+        const auto* limited = sent.error().as<AuthenticationRateLimitError>();
+        if(limited != nullptr)
+        {
+            response.status = 429;
+            response.set_header(
+                "Retry-After", std::to_string(limited->retry_after));
+            response.set_content(limited->msg + "\n", "text/plain");
+            return;
+        }
+        respondOperationError(
+            response, sent.error(), "Failed to send authentication email");
+        return;
+    }
+    setCookie(response, "card_collection_auth_form", "", 0, true);
+    response.status = 303;
+    response.set_header("Location", urlFor("authentication-sent"));
+}
+
+void App::handleAuthenticationSent(
     [[maybe_unused]] const Request& request,
     Response& response)
 {
+    response.set_header("Cache-Control", "no-store");
+    respondTemplate(templates_, "authentication_sent.html", {
+        {"title", "Check your email · Card Collection"},
+    }, response);
+}
+
+void App::handleAuthenticationConfirm(
+    const Request& request, Response& response)
+{
+    const auto parameter = request.path_params.find("token");
+    if(parameter == request.path_params.end())
+    {
+        respondBadRequest(response, "Invalid authentication link");
+        return;
+    }
+    auto challenge = authentication_service_->validate(parameter->second);
+    if(!challenge)
+    {
+        const auto* http_error = challenge.error().as<mw::HTTPError>();
+        if(http_error != nullptr && http_error->code == 400)
+        {
+            respondBadRequest(response, "Invalid authentication link");
+        }
+        else
+        {
+            spdlog::error(
+                "Failed to validate authentication link: {}",
+                challenge.error().msg());
+            respondInternalError(response);
+        }
+        return;
+    }
+    if(!*challenge)
+    {
+        respondBadRequest(response, "Invalid or expired authentication link");
+        return;
+    }
+    auto nonce = generateSecretToken(*crypto_);
+    if(!nonce)
+    {
+        respondInternalError(response);
+        return;
+    }
+    setCookie(
+        response, "card_collection_confirm_form", nonce->value, 600, true);
+    response.set_header("Cache-Control", "no-store");
+    response.set_header("Referrer-Policy", "no-referrer");
+    response.set_header(
+        "Content-Security-Policy", "default-src 'none'; style-src 'self'");
+    respondTemplate(templates_, "authentication_confirm.html", {
+        {"action_url",
+         urlFor("authentication-confirm", {parameter->second})},
+        {"email", (**challenge).email},
+        {"form_nonce", nonce->value},
+        {"title", "Confirm sign in · Card Collection"},
+    }, response);
+}
+
+void App::handleAuthenticationConfirmPost(
+    const Request& request, Response& response)
+{
+    const auto parameter = request.path_params.find("token");
+    const auto nonce = cookieValue(request, "card_collection_confirm_form");
+    if(parameter == request.path_params.end() || !nonce ||
+       request.get_param_value_count("form_nonce") != 1 ||
+       !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
+    {
+        response.status = 403;
+        response.set_content("Invalid confirmation token\n", "text/plain");
+        return;
+    }
+    auto current = cookieValue(request, "card_collection_session");
+    auto established = authentication_service_->confirm(
+        parameter->second, current);
+    if(!established)
+    {
+        respondOperationError(
+            response, established.error(), "Failed to confirm authentication");
+        return;
+    }
+    setCookie(
+        response,
+        "card_collection_session",
+        established->token,
+        28 * 24 * 60 * 60,
+        false);
+    setCookie(response, "card_collection_confirm_form", "", 0, true);
+    response.status = 303;
+    response.set_header(
+        "Location",
+        established->user.username
+            ? urlFor("collection")
+            : urlFor("onboarding"));
+}
+
+void App::handleLogout(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false, true);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    auto logged_out = authentication_service_->logout(identity->raw_token);
+    if(!logged_out)
+    {
+        respondInternalError(response);
+        return;
+    }
+    setCookie(response, "card_collection_session", "", 0, false);
+    response.status = 303;
+    response.set_header("Location", urlFor("welcome"));
+}
+
+void App::handleOnboarding(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true, true);
+    if(!identity)
+    {
+        return;
+    }
+    if(identity->session.user.username)
+    {
+        response.status = 303;
+        response.set_header("Location", urlFor("collection"));
+        return;
+    }
+    respondTemplate(templates_, "onboarding_username.html", {
+        {"action_url", urlFor("onboarding")},
+        {"csrf_token", identity->session.csrf_token},
+        {"heading", "Choose a username"},
+        {"submit_label", "Continue"},
+        {"title", "Choose username · Card Collection"},
+        {"username", ""},
+    }, response);
+}
+
+void App::handleOnboardingPost(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false, true);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    if(identity->session.user.username ||
+       request.get_param_value_count("username") != 1)
+    {
+        respondBadRequest(response, "Username is required");
+        return;
+    }
+    auto updated = user_service_->setUsername(
+        identity->session.user.id, request.get_param_value("username"));
+    if(!updated)
+    {
+        respondOperationError(response, updated.error(), "Failed username");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("collection"));
+}
+
+void App::handleAccount(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    if(!identity)
+    {
+        return;
+    }
+    respondTemplate(templates_, "account.html", {
+        {"csrf_token", identity->session.csrf_token},
+        {"email", identity->session.user.email},
+        {"logout_url", urlFor("logout")},
+        {"title", "Account · Card Collection"},
+        {"username", *identity->session.user.username},
+        {"username_url", urlFor("account-username")},
+    }, response);
+}
+
+void App::handleAccountUsername(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    if(!identity)
+    {
+        return;
+    }
+    respondTemplate(templates_, "onboarding_username.html", {
+        {"action_url", urlFor("account-username")},
+        {"csrf_token", identity->session.csrf_token},
+        {"heading", "Change username"},
+        {"submit_label", "Save"},
+        {"title", "Change username · Card Collection"},
+        {"username", *identity->session.user.username},
+    }, response);
+}
+
+void App::handleAccountUsernamePost(
+    const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    if(request.get_param_value_count("username") != 1)
+    {
+        respondBadRequest(response, "Username is required");
+        return;
+    }
+    auto updated = user_service_->setUsername(
+        identity->session.user.id, request.get_param_value("username"));
+    if(!updated)
+    {
+        respondOperationError(response, updated.error(), "Failed username");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("account"));
+}
+
+void App::handleCollection(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    if(!identity)
+    {
+        return;
+    }
+    auto user = collection_service_->refresh(identity->session.user.id);
+    auto collection = data_source_->getCollection(identity->session.user.id);
+    auto pool = data_source_->getPoolCards();
+    if(!user || !collection || !pool)
+    {
+        respondInternalError(response);
+        return;
+    }
+    inja::json entries = inja::json::array();
+    for(const CollectionEntry& entry : *collection)
+    {
+        auto public_id_result = formatPublicId(entry.card.identity);
+        if(!public_id_result)
+        {
+            respondInternalError(response);
+            return;
+        }
+        const std::string& public_id = *public_id_result;
+        const std::string thumbnail_name =
+            "thumb." + entry.card.thumbnail_extension;
+        const std::filesystem::path thumbnail_path =
+            config_.card_storage_root / "published" / public_id /
+            thumbnail_name;
+        std::error_code image_error;
+        const bool thumbnail_exists = std::filesystem::is_regular_file(
+            thumbnail_path, image_error);
+        const std::string thumbnail_url = thumbnail_exists
+            ? urlFor(
+                "card-asset",
+                {public_id + '/' + thumbnail_name},
+                {{"v", std::to_string(entry.card.revision)}})
+            : urlFor("static", {"card_placeholder.svg"});
+        entries.push_back({
+            {"name", entry.card.name},
+            {"quantity", entry.quantity},
+            {"thumbnail_url", thumbnail_url},
+            {"url", urlFor("card", {public_id})},
+        });
+    }
+    const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
+    respondTemplate(templates_, "collection.html", {
+        {"available_pulls", user->stored_pulls},
+        {"csrf_token", identity->session.csrf_token},
+        {"entries", std::move(entries)},
+        {"pool_empty", pool->empty()},
+        {"pull_disabled", pull_disabled},
+        {"pull_url", urlFor("collection-pull")},
+        {"title", "Collection · Card Collection"},
+    }, response);
+}
+
+void App::handleCollectionPull(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    auto pulled = collection_service_->pull(identity->session.user.id);
+    if(!pulled)
+    {
+        respondOperationError(response, pulled.error(), "Failed card pull");
+        return;
+    }
+    response.status = 303;
+    auto public_id = formatPublicId(pulled->card.identity);
+    if(!public_id)
+    {
+        respondInternalError(response);
+        return;
+    }
+    response.set_header(
+        "Location", urlFor("card", {*public_id}));
+}
+
+void App::handleAdminUsers(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    if(!identity)
+    {
+        return;
+    }
+    AuthorizationService authorization;
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    auto users = data_source_->getUsers();
+    if(!users)
+    {
+        respondInternalError(response);
+        return;
+    }
+    inja::json template_users = inja::json::array();
+    for(const User& user : *users)
+    {
+        template_users.push_back({
+            {"can_promote", user.role == UserRole::PLAYER},
+            {"email", user.email},
+            {"promote_url", urlFor(
+                "admin-promote", {std::to_string(user.id)})},
+            {"role", roleName(user.role)},
+            {"username", user.username.value_or("Onboarding")},
+        });
+    }
+    respondTemplate(templates_, "user_admin.html", {
+        {"csrf_token", identity->session.csrf_token},
+        {"title", "Users · Card Collection"},
+        {"users", std::move(template_users)},
+    }, response);
+}
+
+void App::handleAdminPromote(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    const auto parameter = request.path_params.find("id");
+    const auto target = parameter == request.path_params.end()
+        ? std::nullopt
+        : parsePositiveId(parameter->second);
+    if(!target)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto promoted = user_service_->promote(
+        identity->session.user.id, *target);
+    if(!promoted)
+    {
+        respondOperationError(response, promoted.error(), "Failed promotion");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("admin-users"));
+}
+
+void App::handleCardNew(
+    const Request& request,
+    Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!identity)
+    {
+        return;
+    }
+    if(!authorization.canCreateCard(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     inja::json games = inja::json::array();
     for(const GameDefinition* game : games_->games())
     {
@@ -505,7 +1239,11 @@ void App::handleCardNew(
     }
     const inja::json template_data = {
         {"action_url", urlFor("cards")},
-        {"back_url", urlFor("card-index")},
+        {"back_url",
+         urlFor(identity->session.user.role == UserRole::ADMINISTRATOR
+             ? "card-index"
+             : "creator-cards")},
+        {"csrf_token", identity->session.csrf_token},
         {"display_id", "New addition"},
         {"foil_action", "keep"},
         {"foil_url", ""},
@@ -526,6 +1264,8 @@ void App::handleCardNew(
         {"spectral_lut_url",
          urlFor("static", {"foil/spectral_xyz.bin"})},
         {"rarity", 0},
+        {"show_rarity",
+         identity->session.user.role == UserRole::ADMINISTRATOR},
         {"revision", 0},
         {"selected_game", ""},
         {"series", std::move(series)},
@@ -555,6 +1295,11 @@ void App::handleCardEdit(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    if(!actor)
+    {
+        return;
+    }
     const auto id_parameter = request.path_params.find("id");
     if(id_parameter == request.path_params.end())
     {
@@ -583,6 +1328,12 @@ void App::handleCardEdit(
         return;
     }
     const Card& card = **card_result;
+    AuthorizationService authorization;
+    if(!authorization.canEditCard(actor->session.user, card))
+    {
+        respondNotFound(response);
+        return;
+    }
     auto public_id_result = formatPublicId(card.identity);
     if(!public_id_result)
     {
@@ -675,8 +1426,9 @@ void App::handleCardEdit(
     }
 
     const inja::json template_data = {
-        {"action_url", urlFor("card", {public_id})},
+        {"action_url", urlFor("card-edit", {public_id})},
         {"back_url", urlFor("card", {public_id})},
+        {"csrf_token", actor->session.csrf_token},
         {"display_id", uppercaseAscii(public_id)},
         {"foil_action", "keep"},
         {"foil_url", foil_url},
@@ -691,6 +1443,8 @@ void App::handleCardEdit(
         {"preview_script_url",
          urlFor("static", {"foil/card_preview.js"})},
         {"rarity", card.rarity},
+        {"show_rarity",
+         actor->session.user.role == UserRole::ADMINISTRATOR},
         {"revision", card.revision},
         {"selected_game", card.identity.game_short_name.value_or("")},
         {"series", std::move(series)},
@@ -728,6 +1482,17 @@ void App::handleCardCreate(
     Response& response,
     const ContentReader& content_reader)
 {
+    auto actor = requireIdentity(request, response, false);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    if(!authorization.canCreateCard(actor->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     if(!request.is_multipart_form_data())
     {
         respondBadRequest(response, "Expected a multipart form upload");
@@ -742,6 +1507,14 @@ void App::handleCardCreate(
             response, upload.error(), "Failed to receive a card upload");
         return;
     }
+    const auto csrf = upload->fields.find("csrf_token");
+    if(csrf == upload->fields.end() ||
+       !constantTimeEqual(csrf->second, actor->session.csrf_token))
+    {
+        response.status = 403;
+        response.set_content("Invalid CSRF token\n", "text/plain");
+        return;
+    }
 
     const auto game = upload->fields.find("game");
     const std::string game_short_name = game == upload->fields.end()
@@ -775,7 +1548,12 @@ void App::handleCardCreate(
         respondBadRequest(response, "Front artwork is required");
         return;
     }
-    auto rarity = parseRarity(upload->fields);
+    const bool rarity_was_submitted = upload->fields.contains("rarity");
+    mw::E<std::int64_t> rarity = std::int64_t{0};
+    if(rarity_was_submitted)
+    {
+        rarity = parseRarity(upload->fields);
+    }
     if(!rarity)
     {
         respondBadRequest(response, rarity.error().msg());
@@ -791,6 +1569,7 @@ void App::handleCardCreate(
         *upload->front,
         upload->foil,
         upload->thumbnail,
+        rarity_was_submitted,
     };
     mw::E<std::string> created = std::unexpected(
         mw::runtimeError("Card creation was not dispatched"));
@@ -802,7 +1581,8 @@ void App::handleCardCreate(
                 response, "Loose cards cannot belong to a series");
             return;
         }
-        created = card_service_->createLooseCard(std::move(input));
+        created = card_service_->createLooseCard(
+            actor->session.user.id, std::move(input));
     }
     else
     {
@@ -833,6 +1613,7 @@ void App::handleCardCreate(
             return;
         }
         created = card_service_->createGameCard(
+            actor->session.user.id,
             std::move(input),
             *definition,
             **metadata,
@@ -854,6 +1635,11 @@ void App::handleCardUpdate(
     Response& response,
     const ContentReader& content_reader)
 {
+    auto actor = requireIdentity(request, response, false);
+    if(!actor)
+    {
+        return;
+    }
     const auto id_parameter = request.path_params.find("id");
     if(id_parameter == request.path_params.end())
     {
@@ -881,6 +1667,12 @@ void App::handleCardUpdate(
         respondNotFound(response);
         return;
     }
+    AuthorizationService authorization;
+    if(!authorization.canEditCard(actor->session.user, **card_result))
+    {
+        respondNotFound(response);
+        return;
+    }
     if(!request.is_multipart_form_data())
     {
         respondBadRequest(response, "Expected a multipart form upload");
@@ -895,6 +1687,14 @@ void App::handleCardUpdate(
             response, upload.error(), "Failed to receive a card edit");
         return;
     }
+    const auto csrf = upload->fields.find("csrf_token");
+    if(csrf == upload->fields.end() ||
+       !constantTimeEqual(csrf->second, actor->session.csrf_token))
+    {
+        response.status = 403;
+        response.set_content("Invalid CSRF token\n", "text/plain");
+        return;
+    }
     if(upload->fields.contains("game"))
     {
         respondBadRequest(response, "Card identity cannot be edited");
@@ -922,7 +1722,12 @@ void App::handleCardUpdate(
         respondBadRequest(response, "Card name is too long");
         return;
     }
-    auto rarity = parseRarity(upload->fields);
+    const bool rarity_was_submitted = upload->fields.contains("rarity");
+    mw::E<std::int64_t> rarity = (**card_result).rarity;
+    if(rarity_was_submitted)
+    {
+        rarity = parseRarity(upload->fields);
+    }
     if(!rarity)
     {
         respondBadRequest(response, rarity.error().msg());
@@ -994,6 +1799,7 @@ void App::handleCardUpdate(
         upload->front,
         upload->foil,
         upload->thumbnail,
+        rarity_was_submitted,
     };
     mw::E<std::string> updated = std::unexpected(
         mw::runtimeError("Card update was not dispatched"));
@@ -1005,7 +1811,8 @@ void App::handleCardUpdate(
                 response, "Loose cards cannot belong to a series");
             return;
         }
-        updated = card_service_->updateLooseCard(std::move(input));
+        updated = card_service_->updateLooseCard(
+            actor->session.user.id, std::move(input));
     }
     else
     {
@@ -1038,6 +1845,7 @@ void App::handleCardUpdate(
             return;
         }
         updated = card_service_->updateGameCard(
+            actor->session.user.id,
             std::move(input),
             *definition,
             **metadata,
@@ -1058,6 +1866,11 @@ void App::handleCardView(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    if(!actor)
+    {
+        return;
+    }
     const auto id_parameter = request.path_params.find("id");
     if(id_parameter == request.path_params.end())
     {
@@ -1087,6 +1900,38 @@ void App::handleCardView(
         return;
     }
     const Card& card = **card_result;
+    auto owns = data_source_->userOwnsCard(actor->session.user.id, card.id);
+    AuthorizationService authorization;
+    if(!owns)
+    {
+        respondInternalError(response);
+        return;
+    }
+    if(!authorization.canViewCard(actor->session.user, card, *owns))
+    {
+        respondNotFound(response);
+        return;
+    }
+
+    auto pool_cards = data_source_->getPoolCards();
+    if(!pool_cards)
+    {
+        respondInternalError(response);
+        return;
+    }
+    CardPoolService pool_service;
+    const auto pool = pool_service.calculate(*pool_cards);
+    std::string probability = "Not currently in the pull pool";
+    const auto pool_entry = std::ranges::find_if(
+        pool,
+        [&card](const CardPoolEntry& entry)
+        {
+            return entry.card.id == card.id;
+        });
+    if(pool_entry != pool.end())
+    {
+        probability = formatProbability(pool_entry->probability);
+    }
 
     auto public_id_result = formatPublicId(card.identity);
     if(!public_id_result)
@@ -1257,6 +2102,7 @@ void App::handleCardView(
         {"name", card.name},
         {"preview_script_url",
          urlFor("static", {"foil/card_preview.js"})},
+        {"probability", probability},
         {"rarity", card.rarity},
         {"series", std::move(series)},
         {"shader_fragment_url",
@@ -1294,6 +2140,17 @@ void App::handleCardDeleteConfirm(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    if(!authorization.canDeleteCard(actor->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     const auto parameter = request.path_params.find("id");
     if(parameter == request.path_params.end())
     {
@@ -1325,6 +2182,7 @@ void App::handleCardDeleteConfirm(
     const inja::json template_data = {
         {"action_url", urlFor("card-delete", {parameter->second})},
         {"back_url", urlFor("card", {parameter->second})},
+        {"csrf_token", actor->session.csrf_token},
         {"name", (**card).name},
         {"title", "Delete card · Card Collection"},
     };
@@ -1346,6 +2204,11 @@ void App::handleCardDelete(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, false);
+    if(!actor || !verifyCsrf(request, *actor, response))
+    {
+        return;
+    }
     const auto parameter = request.path_params.find("id");
     if(parameter == request.path_params.end())
     {
@@ -1374,7 +2237,8 @@ void App::handleCardDelete(
         }
         return;
     }
-    auto deleted = card_service_->deleteCard(**card);
+    auto deleted = card_service_->deleteCard(
+        actor->session.user.id, **card);
     if(!deleted)
     {
         respondOperationError(
@@ -1410,7 +2274,24 @@ void App::handleCardIndex(
     const Request& request,
     Response& response)
 {
-    auto cards_result = data_source_->getCards();
+    auto actor = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    const bool creator_route = request.path == getPath("creator-cards");
+    if((creator_route &&
+        !authorization.canCreateCard(actor->session.user)) ||
+       (!creator_route &&
+        !authorization.canAdminister(actor->session.user)))
+    {
+        response.status = 403;
+        return;
+    }
+    auto cards_result = creator_route
+        ? data_source_->getCardsByCreator(actor->session.user.id)
+        : data_source_->getCards();
     if(!cards_result)
     {
         spdlog::error(
@@ -1485,14 +2366,21 @@ void App::handleCardIndex(
         });
     }
 
+    const std::string index_route = creator_route
+        ? "creator-cards"
+        : "card-index";
     const inja::json template_data = {
         {"ascending_url",
-         urlFor("card-index", {}, {{"sort", "id"}, {"direction", "asc"}})},
+         urlFor(index_route, {}, {{"sort", "id"}, {"direction", "asc"}})},
+        {"administrator", !creator_route},
         {"cards", std::move(template_cards)},
+        {"create_url", urlFor("card-new")},
         {"descending", descending},
         {"descending_url",
-         urlFor("card-index", {}, {{"sort", "id"}, {"direction", "desc"}})},
+         urlFor(index_route, {}, {{"sort", "id"}, {"direction", "desc"}})},
+        {"series_url", urlFor("series-index")},
         {"title", "Card Collection"},
+        {"users_url", urlFor("admin-users")},
     };
 
     try
@@ -1510,9 +2398,20 @@ void App::handleCardIndex(
 }
 
 void App::handleSeriesIndex(
-    [[maybe_unused]] const Request& request,
+    const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(actor->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     auto series_result = data_source_->getSeries();
     if(!series_result)
     {
@@ -1581,9 +2480,20 @@ void App::handleSeriesIndex(
 }
 
 void App::handleSeriesNew(
-    [[maybe_unused]] const Request& request,
+    const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(actor->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     inja::json games = inja::json::array();
     for(const GameDefinition* game : games_->games())
     {
@@ -1595,6 +2505,7 @@ void App::handleSeriesNew(
     const inja::json template_data = {
         {"action_url", urlFor("series")},
         {"back_url", urlFor("series-index")},
+        {"csrf_token", actor->session.csrf_token},
         {"description", ""},
         {"game", ""},
         {"games", std::move(games)},
@@ -1622,12 +2533,18 @@ void App::handleSeriesCreate(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, false);
+    if(!actor || !verifyCsrf(request, *actor, response))
+    {
+        return;
+    }
     if(!request.has_param("game") || !request.has_param("name"))
     {
         respondBadRequest(response, "Game and series name are required");
         return;
     }
     auto created = series_service_->create(
+        actor->session.user.id,
         request.get_param_value("game"),
         request.get_param_value("name"),
         request.has_param("description")
@@ -1647,6 +2564,17 @@ void App::handleSeriesEdit(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(actor->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     const auto parameter = request.path_params.find("id");
     const auto id = parameter == request.path_params.end()
         ? std::nullopt
@@ -1676,6 +2604,7 @@ void App::handleSeriesEdit(
     const inja::json template_data = {
         {"action_url", urlFor("series-item", {std::to_string(*id)})},
         {"back_url", urlFor("series-index")},
+        {"csrf_token", actor->session.csrf_token},
         {"description", series.description},
         {"game", game == nullptr
              ? uppercaseAscii(series.game_short_name)
@@ -1705,6 +2634,11 @@ void App::handleSeriesUpdate(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, false);
+    if(!actor || !verifyCsrf(request, *actor, response))
+    {
+        return;
+    }
     const auto parameter = request.path_params.find("id");
     const auto id = parameter == request.path_params.end()
         ? std::nullopt
@@ -1715,6 +2649,7 @@ void App::handleSeriesUpdate(
         return;
     }
     auto updated = series_service_->update(
+        actor->session.user.id,
         *id,
         request.get_param_value("name"),
         request.has_param("description")
@@ -1734,6 +2669,17 @@ void App::handleSeriesDeleteConfirm(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!actor)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(actor->session.user))
+    {
+        response.status = 403;
+        return;
+    }
     const auto parameter = request.path_params.find("id");
     const auto id = parameter == request.path_params.end()
         ? std::nullopt
@@ -1763,6 +2709,7 @@ void App::handleSeriesDeleteConfirm(
     const inja::json template_data = {
         {"action_url", urlFor("series-delete", {std::to_string(*id)})},
         {"back_url", urlFor("series-index")},
+        {"csrf_token", actor->session.csrf_token},
         {"name", (**series_result).name},
         {"title", "Delete series · Card Collection"},
     };
@@ -1785,6 +2732,11 @@ void App::handleSeriesDelete(
     const Request& request,
     Response& response)
 {
+    auto actor = requireIdentity(request, response, false);
+    if(!actor || !verifyCsrf(request, *actor, response))
+    {
+        return;
+    }
     const auto parameter = request.path_params.find("id");
     const auto id = parameter == request.path_params.end()
         ? std::nullopt
@@ -1794,7 +2746,7 @@ void App::handleSeriesDelete(
         respondBadRequest(response, "A valid series ID is required");
         return;
     }
-    auto removed = series_service_->remove(*id);
+    auto removed = series_service_->remove(actor->session.user.id, *id);
     if(!removed)
     {
         respondOperationError(
@@ -1807,6 +2759,25 @@ void App::handleSeriesDelete(
 
 void App::setup()
 {
+    server.set_post_routing_handler(
+        []([[maybe_unused]] const httplib::Request& request,
+           httplib::Response& response)
+        {
+            if(!response.has_header("Content-Security-Policy"))
+            {
+                response.set_header(
+                    "Content-Security-Policy",
+                    "default-src 'self'; img-src 'self' data:; "
+                    "script-src 'self' 'unsafe-inline'; style-src 'self'; "
+                    "object-src 'none'; base-uri 'self'; "
+                    "frame-ancestors 'none'");
+            }
+            if(!response.has_header("Referrer-Policy"))
+            {
+                response.set_header("Referrer-Policy", "same-origin");
+            }
+            response.set_header("X-Content-Type-Options", "nosniff");
+        });
     const std::filesystem::path published_cards =
         config_.card_storage_root / "published";
     if(!server.set_mount_point(
@@ -1820,9 +2791,60 @@ void App::setup()
         spdlog::error("Failed to mount published card assets");
     }
 
+    server.Get(
+        getPath("welcome"),
+        std::bind_front(&App::handleWelcome, this));
+    server.Get(
+        getPath("authentication"),
+        std::bind_front(&App::handleAuthentication, this));
+    server.Post(
+        getPath("authentication-email"),
+        std::bind_front(&App::handleAuthenticationEmail, this));
+    server.Get(
+        getPath("authentication-sent"),
+        std::bind_front(&App::handleAuthenticationSent, this));
+    server.Get(
+        getPath("authentication-confirm", {"token"}),
+        std::bind_front(&App::handleAuthenticationConfirm, this));
+    server.Post(
+        getPath("authentication-confirm", {"token"}),
+        std::bind_front(&App::handleAuthenticationConfirmPost, this));
+    server.Post(
+        getPath("logout"),
+        std::bind_front(&App::handleLogout, this));
+    server.Get(
+        getPath("onboarding"),
+        std::bind_front(&App::handleOnboarding, this));
+    server.Post(
+        getPath("onboarding"),
+        std::bind_front(&App::handleOnboardingPost, this));
+    server.Get(
+        getPath("account"),
+        std::bind_front(&App::handleAccount, this));
+    server.Get(
+        getPath("account-username"),
+        std::bind_front(&App::handleAccountUsername, this));
+    server.Post(
+        getPath("account-username"),
+        std::bind_front(&App::handleAccountUsernamePost, this));
+    server.Get(
+        getPath("collection"),
+        std::bind_front(&App::handleCollection, this));
+    server.Post(
+        getPath("collection-pull"),
+        std::bind_front(&App::handleCollectionPull, this));
+    server.Get(
+        getPath("admin-users"),
+        std::bind_front(&App::handleAdminUsers, this));
+    server.Post(
+        getPath("admin-promote", {"id"}),
+        std::bind_front(&App::handleAdminPromote, this));
     server.Get(
         getPath("card-index"),
         std::bind_front(&App::handleCardIndex, this));
+    server.Get(
+        getPath("creator-cards"),
+        std::bind_front(&App::handleCardIndex, this));
     server.Get(
         getPath("card-new"),
         std::bind_front(&App::handleCardNew, this));
@@ -1836,7 +2858,7 @@ void App::setup()
         getPath("cards"),
         std::bind_front(&App::handleCardCreate, this));
     server.Post(
-        getPath("card", {"id"}),
+        getPath("card-edit", {"id"}),
         std::bind_front(&App::handleCardUpdate, this));
     server.Get(
         getPath("card-delete", {"id"}),
diff --git a/src/app.h b/src/app.h
index 2104776..b5fdccf 100644
--- a/src/app.h
+++ b/src/app.h
@@ -1,19 +1,26 @@
 #pragma once
 
 #include <memory>
+#include <optional>
 #include <string>
 #include <vector>
 
 #include <inja/inja.hpp>
+#include <mw/crypto.hpp>
 #include <mw/http_server.hpp>
 
+#include "authentication.h"
 #include "card_service.h"
+#include "clock.h"
+#include "collection.h"
 #include "config.h"
 #include "data.h"
+#include "email_sender.h"
 #include "game_registry.h"
 #include "non_secret_random.h"
 #include "series_service.h"
 #include "url_builder.h"
+#include "user_service.h"
 
 /// Card Collection HTTP application and named-route owner.
 class App : public mw::HTTPServer
@@ -47,6 +54,59 @@ public:
     /// Render the card index.
     void handleCardIndex(const Request& request, Response& response);
 
+    /// Redirect an authenticated visitor or render the public welcome page.
+    void handleWelcome(const Request& request, Response& response);
+
+    /// Render the passwordless email request form.
+    void handleAuthentication(const Request& request, Response& response);
+
+    /// Reserve and send a passwordless authentication link.
+    void handleAuthenticationEmail(
+        const Request& request, Response& response);
+
+    /// Render the neutral authentication-email acknowledgement.
+    void handleAuthenticationSent(
+        const Request& request, Response& response);
+
+    /// Render a non-consuming authentication confirmation form.
+    void handleAuthenticationConfirm(
+        const Request& request, Response& response);
+
+    /// Consume an authentication challenge and establish a session.
+    void handleAuthenticationConfirmPost(
+        const Request& request, Response& response);
+
+    /// Revoke the current session.
+    void handleLogout(const Request& request, Response& response);
+
+    /// Render the required initial-username form.
+    void handleOnboarding(const Request& request, Response& response);
+
+    /// Persist the required initial username.
+    void handleOnboardingPost(const Request& request, Response& response);
+
+    /// Render the current account summary.
+    void handleAccount(const Request& request, Response& response);
+
+    /// Render the username-change form.
+    void handleAccountUsername(const Request& request, Response& response);
+
+    /// Persist a username change.
+    void handleAccountUsernamePost(
+        const Request& request, Response& response);
+
+    /// Render the current user's card collection and pull balance.
+    void handleCollection(const Request& request, Response& response);
+
+    /// Perform one atomic collection pull.
+    void handleCollectionPull(const Request& request, Response& response);
+
+    /// Render the administrator's user table.
+    void handleAdminUsers(const Request& request, Response& response);
+
+    /// Permanently promote one player to creator.
+    void handleAdminPromote(const Request& request, Response& response);
+
     /// Render the create-card form.
     void handleCardNew(const Request& request, Response& response);
 
@@ -100,6 +160,12 @@ public:
     void handleSeriesDelete(const Request& request, Response& response);
 
 private:
+    struct RequestIdentity
+    {
+        SessionContext session;
+        std::string raw_token;
+    };
+
     /// Register implemented handlers and static mounts.
     void setup() override;
 
@@ -111,10 +177,37 @@ private:
     /// Return the request prefix for a named static mount.
     std::string getMountPath(const std::string& name) const;
 
+    /// Load and authorize one browser session, writing failures to response.
+    std::optional<RequestIdentity> requireIdentity(
+        const Request& request,
+        Response& response,
+        bool safe_get,
+        bool allow_onboarding = false);
+
+    /// Verify one authenticated mutation's synchronizer token.
+    bool verifyCsrf(
+        const Request& request,
+        const RequestIdentity& identity,
+        Response& response) const;
+
+    /// Set or expire a host-only application cookie.
+    void setCookie(
+        Response& response,
+        const std::string& name,
+        const std::string& value,
+        std::int64_t maximum_age,
+        bool strict_same_site) const;
+
     Config config_;
     std::unique_ptr<DataSourceInterface> data_source_;
     std::unique_ptr<GameRegistry> games_;
     std::unique_ptr<NonSecretRandom> random_;
+    std::unique_ptr<ClockInterface> clock_;
+    std::unique_ptr<mw::CryptoInterface> crypto_;
+    std::unique_ptr<EmailSenderInterface> email_sender_;
+    std::unique_ptr<AuthenticationService> authentication_service_;
+    std::unique_ptr<UserService> user_service_;
+    std::unique_ptr<CollectionService> collection_service_;
     std::unique_ptr<CardService> card_service_;
     std::unique_ptr<SeriesService> series_service_;
     UrlBuilder url_builder_;
diff --git a/src/authentication.cpp b/src/authentication.cpp
new file mode 100644
index 0000000..d93014b
--- /dev/null
+++ b/src/authentication.cpp
@@ -0,0 +1,317 @@
+#include "authentication.h"
+
+#include <chrono>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "email_address.h"
+#include "secret_token.h"
+
+namespace
+{
+
+constexpr std::int64_t CHALLENGE_LIFETIME_SECONDS = 10 * 60;
+constexpr std::int64_t SESSION_LIFETIME_SECONDS = 28 * 24 * 60 * 60;
+constexpr int TOKEN_ATTEMPTS = 3;
+
+} // namespace
+
+AuthenticationService::AuthenticationService(
+    DataSourceInterface& data_source,
+    EmailSenderInterface& email_sender,
+    ClockInterface& clock,
+    mw::CryptoInterface& crypto,
+    mw::URL base_url,
+    std::uint32_t daily_attempt_limit)
+    : data_source_(data_source),
+      email_sender_(email_sender),
+      clock_(clock),
+      crypto_(crypto),
+      base_url_(std::move(base_url)),
+      daily_attempt_limit_(daily_attempt_limit)
+{}
+
+std::int64_t AuthenticationService::nowSeconds() const
+{
+    return std::chrono::duration_cast<std::chrono::seconds>(
+        clock_.now().time_since_epoch()).count();
+}
+
+mw::URL AuthenticationService::confirmationUrl(
+    const std::string& token) const
+{
+    mw::URL url = base_url_;
+    url.appendPath("authentication/confirm");
+    url.appendPath(token);
+    return url;
+}
+
+mw::E<void> AuthenticationService::requestEmail(
+    const std::string& submitted_email)
+{
+    auto address = normalizeEmail(submitted_email);
+    if(!address)
+    {
+        return std::unexpected(mw::httpError(400, "Invalid email address"));
+    }
+    const std::int64_t now = nowSeconds();
+    const std::int64_t day = utcDay(clock_.now());
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto reservation = (*transaction)->reserveAuthenticationEmail(
+        address->key,
+        now,
+        email_sender_.usesGlobalQuota(),
+        day,
+        daily_attempt_limit_);
+    if(!reservation)
+    {
+        return std::unexpected(std::move(reservation.error()));
+    }
+    if(reservation->status != AuthenticationReservationStatus::RESERVED)
+    {
+        return std::unexpected(mw::Error(AuthenticationRateLimitError{
+            "Authentication email rate limit exceeded",
+            reservation->retry_after}));
+    }
+
+    std::optional<SecretToken> challenge_token;
+    std::optional<std::int64_t> challenge_id;
+    for(int attempt = 0; attempt < TOKEN_ATTEMPTS; ++attempt)
+    {
+        auto generated = generateSecretToken(crypto_);
+        if(!generated)
+        {
+            return std::unexpected(std::move(generated.error()));
+        }
+        auto inserted = (*transaction)->insertAuthenticationChallenge(
+            address->email,
+            address->key,
+            generated->hash,
+            now,
+            now + CHALLENGE_LIFETIME_SECONDS);
+        if(inserted)
+        {
+            challenge_token = std::move(*generated);
+            challenge_id = *inserted;
+            break;
+        }
+    }
+    if(!challenge_token || !challenge_id)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to allocate a unique authentication challenge"));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+
+    AuthenticationEmail email = {
+        address->email,
+        confirmationUrl(challenge_token->value),
+        std::chrono::system_clock::time_point(
+            std::chrono::seconds(now + CHALLENGE_LIFETIME_SECONDS))};
+    auto sent = email_sender_.send(email);
+    transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    if(!sent)
+    {
+        auto deleted = (*transaction)->deleteAuthenticationChallenge(
+            *challenge_id);
+        if(!deleted)
+        {
+            return std::unexpected(std::move(deleted.error()));
+        }
+        auto deletion_commit = (*transaction)->commit();
+        if(!deletion_commit)
+        {
+            return std::unexpected(std::move(deletion_commit.error()));
+        }
+        return std::unexpected(mw::httpError(
+            502, "Authentication email delivery failed"));
+    }
+    auto delivered = (*transaction)->markAuthenticationChallengeDelivered(
+        *challenge_id, nowSeconds());
+    if(!delivered)
+    {
+        return std::unexpected(std::move(delivered.error()));
+    }
+    if(!*delivered)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Authentication challenge disappeared after delivery"));
+    }
+    auto delivery_commit = (*transaction)->commit();
+    if(!delivery_commit)
+    {
+        return std::unexpected(std::move(delivery_commit.error()));
+    }
+    return {};
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+AuthenticationService::validate(const std::string& token) const
+{
+    auto hash = hashSecretToken(token);
+    if(!hash)
+    {
+        return std::unexpected(mw::httpError(400, "Invalid link"));
+    }
+    return data_source_.getAuthenticationChallenge(*hash, nowSeconds());
+}
+
+mw::E<EstablishedSession> AuthenticationService::confirm(
+    const std::string& token,
+    const std::optional<std::string>& current_session_token)
+{
+    auto challenge_hash = hashSecretToken(token);
+    if(!challenge_hash)
+    {
+        return std::unexpected(mw::httpError(400, "Invalid link"));
+    }
+    std::optional<TokenHash> current_session_hash;
+    if(current_session_token)
+    {
+        auto hash = hashSecretToken(*current_session_token);
+        if(hash)
+        {
+            current_session_hash = std::move(*hash);
+        }
+    }
+    const std::int64_t now = nowSeconds();
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto challenge = (*transaction)->consumeAuthenticationChallenge(
+        *challenge_hash, now);
+    if(!challenge)
+    {
+        return std::unexpected(std::move(challenge.error()));
+    }
+    if(!*challenge)
+    {
+        return std::unexpected(mw::httpError(400, "Invalid or expired link"));
+    }
+    auto user = (*transaction)->getUserByEmailKeyForUpdate(
+        (**challenge).email_key);
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    if(!*user)
+    {
+        User new_user = {
+            0,
+            (**challenge).email,
+            (**challenge).email_key,
+            std::nullopt,
+            UserRole::PLAYER,
+            1,
+            utcDay(clock_.now()),
+            now};
+        auto user_id = (*transaction)->insertUser(new_user);
+        if(!user_id)
+        {
+            return std::unexpected(std::move(user_id.error()));
+        }
+        new_user.id = *user_id;
+        user = std::optional<User>(std::move(new_user));
+    }
+    auto invalidated = (*transaction)->invalidateAuthenticationChallenges(
+        (**challenge).email_key, (**challenge).id, now);
+    if(!invalidated)
+    {
+        return std::unexpected(std::move(invalidated.error()));
+    }
+    if(current_session_hash)
+    {
+        auto deleted = (*transaction)->deleteSession(*current_session_hash);
+        if(!deleted)
+        {
+            return std::unexpected(std::move(deleted.error()));
+        }
+    }
+    const std::int64_t expires_at = now + SESSION_LIFETIME_SECONDS;
+    std::optional<SecretToken> session_token;
+    std::optional<SecretToken> csrf_token;
+    for(int attempt = 0; attempt < TOKEN_ATTEMPTS; ++attempt)
+    {
+        auto generated_session = generateSecretToken(crypto_);
+        auto generated_csrf = generateSecretToken(crypto_);
+        if(!generated_session || !generated_csrf)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Failed to generate session credentials"));
+        }
+        auto inserted = (*transaction)->insertSession(
+            (**user).id,
+            generated_session->hash,
+            generated_csrf->value,
+            now,
+            expires_at);
+        if(inserted)
+        {
+            session_token = std::move(*generated_session);
+            csrf_token = std::move(*generated_csrf);
+            break;
+        }
+    }
+    if(!session_token || !csrf_token)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to allocate unique session credentials"));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return EstablishedSession{
+        std::move(**user),
+        std::move(session_token->value),
+        std::move(csrf_token->value),
+        expires_at};
+}
+
+mw::E<std::optional<SessionContext>> AuthenticationService::session(
+    const std::string& token) const
+{
+    auto hash = hashSecretToken(token);
+    if(!hash)
+    {
+        return std::optional<SessionContext>{};
+    }
+    return data_source_.getSession(*hash, nowSeconds());
+}
+
+mw::E<void> AuthenticationService::logout(const std::string& token)
+{
+    auto hash = hashSecretToken(token);
+    if(!hash)
+    {
+        return {};
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto deleted = (*transaction)->deleteSession(*hash);
+    if(!deleted)
+    {
+        return std::unexpected(std::move(deleted.error()));
+    }
+    return (*transaction)->commit();
+}
diff --git a/src/authentication.h b/src/authentication.h
new file mode 100644
index 0000000..cf34a2a
--- /dev/null
+++ b/src/authentication.h
@@ -0,0 +1,87 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+#include <mw/url.hpp>
+
+#include "clock.h"
+#include "data.h"
+#include "email_sender.h"
+
+/// Authentication-email throttle result with an HTTP retry delay.
+struct AuthenticationRateLimitError
+{
+    /// Non-secret user-facing diagnostic.
+    std::string msg;
+
+    /// Whole seconds until another reservation may succeed.
+    std::int64_t retry_after;
+};
+
+/// Raw session credential returned once after successful confirmation.
+struct EstablishedSession
+{
+    /// Current authenticated user.
+    User user;
+
+    /// Raw session token placed only in the browser cookie.
+    std::string token;
+
+    /// Raw CSRF token included in authenticated mutation forms.
+    std::string csrf_token;
+
+    /// Non-sliding Unix session expiry.
+    std::int64_t expires_at;
+};
+
+/// Own passwordless challenges, delivery, sessions, and logout.
+class AuthenticationService
+{
+public:
+    /// Construct authentication over injected persistence and boundaries.
+    AuthenticationService(
+        DataSourceInterface& data_source,
+        EmailSenderInterface& email_sender,
+        ClockInterface& clock,
+        mw::CryptoInterface& crypto,
+        mw::URL base_url,
+        std::uint32_t daily_attempt_limit);
+
+    /// Reserve capacity, persist a challenge, and deliver its link.
+    mw::E<void> requestEmail(const std::string& submitted_email);
+
+    /// Read a valid delivered challenge without consuming it.
+    mw::E<std::optional<AuthenticationChallenge>> validate(
+        const std::string& token) const;
+
+    /// Consume a challenge, create or load its user, and start a session.
+    mw::E<EstablishedSession> confirm(
+        const std::string& token,
+        const std::optional<std::string>& current_session_token =
+            std::nullopt);
+
+    /// Load a valid non-sliding session from a raw cookie value.
+    mw::E<std::optional<SessionContext>> session(
+        const std::string& token) const;
+
+    /// Revoke only the presented current session.
+    mw::E<void> logout(const std::string& token);
+
+private:
+    /// Return current signed Unix seconds.
+    std::int64_t nowSeconds() const;
+
+    /// Build the absolute confirmation URL for one raw token.
+    mw::URL confirmationUrl(const std::string& token) const;
+
+    DataSourceInterface& data_source_;
+    EmailSenderInterface& email_sender_;
+    ClockInterface& clock_;
+    mw::CryptoInterface& crypto_;
+    mw::URL base_url_;
+    std::uint32_t daily_attempt_limit_;
+};
diff --git a/src/authorization.cpp b/src/authorization.cpp
new file mode 100644
index 0000000..9a61c30
--- /dev/null
+++ b/src/authorization.cpp
@@ -0,0 +1,38 @@
+#include "authorization.h"
+
+bool AuthorizationService::canViewCard(
+    const User& actor, const Card& card, bool actor_owns_card) const
+{
+    return actor_owns_card || actor.role == UserRole::ADMINISTRATOR ||
+           (actor.role == UserRole::CREATOR &&
+            card.creator_user_id == actor.id);
+}
+
+bool AuthorizationService::canCreateCard(const User& actor) const
+{
+    return actor.role == UserRole::CREATOR ||
+           actor.role == UserRole::ADMINISTRATOR;
+}
+
+bool AuthorizationService::canEditCard(
+    const User& actor, const Card& card) const
+{
+    return actor.role == UserRole::ADMINISTRATOR ||
+           (actor.role == UserRole::CREATOR &&
+            card.creator_user_id == actor.id);
+}
+
+bool AuthorizationService::canDeleteCard(const User& actor) const
+{
+    return actor.role == UserRole::ADMINISTRATOR;
+}
+
+bool AuthorizationService::canSetRarity(const User& actor) const
+{
+    return actor.role == UserRole::ADMINISTRATOR;
+}
+
+bool AuthorizationService::canAdminister(const User& actor) const
+{
+    return actor.role == UserRole::ADMINISTRATOR;
+}
diff --git a/src/authorization.h b/src/authorization.h
new file mode 100644
index 0000000..1129971
--- /dev/null
+++ b/src/authorization.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include "card.h"
+#include "user.h"
+
+/// Side-effect-free application permission policy.
+class AuthorizationService
+{
+public:
+    /// Return whether an actor may view a card through the WebGL page.
+    bool canViewCard(const User& actor, const Card& card,
+                     bool actor_owns_card) const;
+
+    /// Return whether an actor may create a card.
+    bool canCreateCard(const User& actor) const;
+
+    /// Return whether an actor may edit a card.
+    bool canEditCard(const User& actor, const Card& card) const;
+
+    /// Return whether an actor may delete cards.
+    bool canDeleteCard(const User& actor) const;
+
+    /// Return whether an actor may set card rarity.
+    bool canSetRarity(const User& actor) const;
+
+    /// Return whether an actor may manage series and users.
+    bool canAdminister(const User& actor) const;
+};
diff --git a/src/card.h b/src/card.h
index a6cc3e2..5b81b44 100644
--- a/src/card.h
+++ b/src/card.h
@@ -46,6 +46,9 @@ struct Card
 
     /// Optimistic-concurrency and asset-cache revision.
     std::int64_t revision;
+
+    /// Internal user ID of the account that created this card.
+    std::int64_t creator_user_id = 0;
 };
 
 /// Logical card image used consistently by validation, storage, and URLs.
diff --git a/src/card_pool.cpp b/src/card_pool.cpp
new file mode 100644
index 0000000..f275f7a
--- /dev/null
+++ b/src/card_pool.cpp
@@ -0,0 +1,117 @@
+#include "card_pool.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <iterator>
+#include <iomanip>
+#include <sstream>
+#include <string>
+#include <vector>
+
+std::vector<CardPoolEntry> CardPoolService::calculate(
+    const std::vector<Card>& cards) const
+{
+    std::vector<Card> eligible;
+    std::ranges::copy_if(cards, std::back_inserter(eligible),
+                        [](const Card& card)
+    {
+        return card.rarity > 0;
+    });
+    std::ranges::sort(eligible, {}, &Card::id);
+    if(eligible.empty())
+    {
+        return {};
+    }
+    const std::int64_t minimum = std::ranges::min(
+        eligible, {}, &Card::rarity).rarity;
+    double total = 0;
+    std::vector<CardPoolEntry> result;
+    result.reserve(eligible.size());
+    for(const Card& card : eligible)
+    {
+        const double weight = std::exp2(
+            static_cast<double>(minimum) -
+            static_cast<double>(card.rarity));
+        total += weight;
+        result.push_back({card, weight, 0});
+    }
+    for(CardPoolEntry& entry : result)
+    {
+        entry.probability = entry.scaled_weight / total;
+    }
+    return result;
+}
+
+mw::E<CardPoolEntry> CardPoolService::select(
+    const std::vector<CardPoolEntry>& entries,
+    mw::CryptoInterface& crypto) const
+{
+    if(entries.empty())
+    {
+        return std::unexpected(mw::runtimeError("Card pool is empty"));
+    }
+    auto bytes = crypto.randomBytes(8);
+    if(!bytes)
+    {
+        return std::unexpected(std::move(bytes.error()));
+    }
+    std::uint64_t value = 0;
+    for(std::byte byte : *bytes)
+    {
+        value = (value << 8) | std::to_integer<std::uint64_t>(byte);
+    }
+    const std::uint64_t high_53 = value >> 11;
+    const double fraction = std::ldexp(static_cast<double>(high_53), -53);
+    double total = 0;
+    for(const CardPoolEntry& entry : entries)
+    {
+        total += entry.scaled_weight;
+    }
+    const double target = fraction * total;
+    double cumulative = 0;
+    const CardPoolEntry* last_positive = nullptr;
+    for(const CardPoolEntry& entry : entries)
+    {
+        if(entry.scaled_weight == 0)
+        {
+            continue;
+        }
+        last_positive = &entry;
+        cumulative += entry.scaled_weight;
+        if(target < cumulative)
+        {
+            return entry;
+        }
+    }
+    if(last_positive != nullptr)
+    {
+        return *last_positive;
+    }
+    return std::unexpected(mw::runtimeError(
+        "Card pool has no represented weight"));
+}
+
+std::string formatProbability(double probability)
+{
+    if(probability <= 0)
+    {
+        return "0%";
+    }
+    const double percentage = probability * 100;
+    if(percentage < 0.000001)
+    {
+        return "<0.000001%";
+    }
+    std::ostringstream output;
+    output << std::fixed << std::setprecision(6) << percentage;
+    std::string result = output.str();
+    result.erase(result.find_last_not_of('0') + 1);
+    if(result.ends_with('.'))
+    {
+        result.pop_back();
+    }
+    return result + '%';
+}
diff --git a/src/card_pool.h b/src/card_pool.h
new file mode 100644
index 0000000..c095cec
--- /dev/null
+++ b/src/card_pool.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+
+#include "card.h"
+
+/// Current pool weight and normalized probability for one card.
+struct CardPoolEntry
+{
+    /// Eligible card.
+    Card card;
+
+    /// Weight scaled so the largest represented weight is one.
+    double scaled_weight;
+
+    /// Weight divided by the represented total.
+    double probability;
+};
+
+/// Calculate and sample the current positive-rarity card pool.
+class CardPoolService
+{
+public:
+    /// Build probability entries in card-ID order.
+    std::vector<CardPoolEntry> calculate(
+        const std::vector<Card>& cards) const;
+
+    /// Select one entry using eight cryptographically random bytes.
+    mw::E<CardPoolEntry> select(
+        const std::vector<CardPoolEntry>& entries,
+        mw::CryptoInterface& crypto) const;
+};
+
+/// Format a represented pool probability for a card page.
+std::string formatProbability(double probability);
diff --git a/src/card_service.cpp b/src/card_service.cpp
index 408a364..7edfe13 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -48,22 +48,29 @@ CardService::CardService(
 {}
 
 mw::E<std::string> CardService::createLooseCard(
-    CreateLooseCardInput input)
+    std::int64_t actor_user_id, CreateLooseCardInput input)
 {
-    return createCard(std::move(input), nullptr, nullptr, {});
+    return createCard(
+        actor_user_id, std::move(input), nullptr, nullptr, {});
 }
 
 mw::E<std::string> CardService::createGameCard(
+    std::int64_t actor_user_id,
     CreateCardInput input,
     const GameDefinition& game,
     const GameCardMetadata& metadata,
     const std::vector<std::int64_t>& series_ids)
 {
     return createCard(
-        std::move(input), &game, &metadata, series_ids);
+        actor_user_id,
+        std::move(input),
+        &game,
+        &metadata,
+        series_ids);
 }
 
 mw::E<std::string> CardService::createCard(
+    std::int64_t actor_user_id,
     CreateCardInput input,
     const GameDefinition* game,
     const GameCardMetadata* metadata,
@@ -144,6 +151,25 @@ mw::E<std::string> CardService::createCard(
     {
         return std::unexpected(std::move(transaction.error()));
     }
+    auto actor = (*transaction)->getUserForUpdate(actor_user_id);
+    if(!actor)
+    {
+        return std::unexpected(std::move(actor.error()));
+    }
+    if(!*actor || !authorization_.canCreateCard(**actor))
+    {
+        return std::unexpected(mw::httpError(403, "Forbidden"));
+    }
+    if(!(**actor).username)
+    {
+        return std::unexpected(mw::httpError(
+            409, "Username onboarding is required"));
+    }
+    if((**actor).role == UserRole::CREATOR && input.rarity_was_submitted)
+    {
+        return std::unexpected(mw::httpError(
+            400, "Creators cannot submit card rarity"));
+    }
 
     std::uint64_t number = 0;
     if(game != nullptr)
@@ -190,11 +216,12 @@ mw::E<std::string> CardService::createCard(
         std::move(input.name),
         std::move(input.short_description),
         std::move(input.long_description),
-        input.rarity,
+        (**actor).role == UserRole::CREATOR ? 0 : input.rarity,
         front->extension,
         foil ? std::optional<std::string>(foil->extension) : std::nullopt,
         thumbnail.extension,
         1,
+        actor_user_id,
     };
     auto inserted = (*transaction)->insertCard(
         card, game, metadata, series_ids);
@@ -225,28 +252,44 @@ mw::E<std::string> CardService::createCard(
 }
 
 mw::E<std::string> CardService::updateLooseCard(
-    UpdateLooseCardInput input)
+    std::int64_t actor_user_id, UpdateLooseCardInput input)
 {
-    return updateCard(std::move(input), nullptr, nullptr, {});
+    return updateCard(
+        actor_user_id, std::move(input), nullptr, nullptr, {});
 }
 
 mw::E<std::string> CardService::updateGameCard(
+    std::int64_t actor_user_id,
     UpdateLooseCardInput input,
     const GameDefinition& game,
     const GameCardMetadata& metadata,
     const std::vector<std::int64_t>& series_ids)
 {
     return updateCard(
-        std::move(input), &game, &metadata, series_ids);
+        actor_user_id,
+        std::move(input),
+        &game,
+        &metadata,
+        series_ids);
 }
 
 mw::E<std::string> CardService::updateCard(
+    std::int64_t actor_user_id,
     UpdateLooseCardInput input,
     const GameDefinition* game,
     const GameCardMetadata* metadata,
     const std::vector<std::int64_t>& series_ids)
 {
-    Card& card = input.current_card;
+    auto stored = data_source_.getCard(input.current_card.identity);
+    if(!stored)
+    {
+        return std::unexpected(std::move(stored.error()));
+    }
+    if(!*stored)
+    {
+        return std::unexpected(mw::httpError(404, "Card not found"));
+    }
+    Card card = std::move(**stored);
     if(input.short_description)
     {
         auto rendered = markdown_renderer_.render(*input.short_description);
@@ -413,7 +456,6 @@ mw::E<std::string> CardService::updateCard(
     card.name = std::move(input.name);
     card.short_description = std::move(input.short_description);
     card.long_description = std::move(input.long_description);
-    card.rarity = input.rarity;
     ++card.revision;
 
     auto transaction = data_source_.beginTransaction();
@@ -435,6 +477,31 @@ mw::E<std::string> CardService::updateCard(
         return std::unexpected(mw::httpError(
             409, "The card was changed in another request"));
     }
+    auto actor = (*transaction)->getUserForUpdate(actor_user_id);
+    if(!actor)
+    {
+        return std::unexpected(std::move(actor.error()));
+    }
+    if(!*actor || !authorization_.canEditCard(**actor, **current))
+    {
+        return std::unexpected(mw::httpError(404, "Card not found"));
+    }
+    if(!(**actor).username)
+    {
+        return std::unexpected(mw::httpError(
+            409, "Username onboarding is required"));
+    }
+    if((**actor).role == UserRole::CREATOR && input.rarity_was_submitted)
+    {
+        return std::unexpected(mw::httpError(
+            400, "Creators cannot submit card rarity"));
+    }
+    card.id = (**current).id;
+    card.identity = (**current).identity;
+    card.creator_user_id = (**current).creator_user_id;
+    card.rarity = (**actor).role == UserRole::CREATOR
+        ? (**current).rarity
+        : input.rarity;
 
     auto updated = (*transaction)->updateCard(
         card, game, metadata, series_ids);
@@ -471,13 +538,9 @@ mw::E<std::string> CardService::updateCard(
     return *public_id;
 }
 
-mw::E<void> CardService::deleteCard(const Card& card)
+mw::E<void> CardService::deleteCard(
+    std::int64_t actor_user_id, const Card& card)
 {
-    auto public_id = formatPublicId(card.identity);
-    if(!public_id)
-    {
-        return std::unexpected(std::move(public_id.error()));
-    }
     auto transaction = data_source_.beginTransaction();
     if(!transaction)
     {
@@ -492,13 +555,32 @@ mw::E<void> CardService::deleteCard(const Card& card)
     {
         return std::unexpected(mw::httpError(404, "Card not found"));
     }
+    auto actor = (*transaction)->getUserForUpdate(actor_user_id);
+    if(!actor)
+    {
+        return std::unexpected(std::move(actor.error()));
+    }
+    if(!*actor || !authorization_.canDeleteCard(**actor))
+    {
+        return std::unexpected(mw::httpError(403, "Forbidden"));
+    }
+    if(!(**actor).username)
+    {
+        return std::unexpected(mw::httpError(
+            409, "Username onboarding is required"));
+    }
+    auto public_id = formatPublicId((**current).identity);
+    if(!public_id)
+    {
+        return std::unexpected(std::move(public_id.error()));
+    }
     auto trashed = asset_store_.trash(
         *public_id, (**current).revision, random_.hex(16));
     if(!trashed)
     {
         return std::unexpected(std::move(trashed.error()));
     }
-    auto deleted = (*transaction)->deleteCard(card.id);
+    auto deleted = (*transaction)->deleteCard((**current).id);
     if(!deleted)
     {
         asset_store_.restore(*trashed);
diff --git a/src/card_service.h b/src/card_service.h
index 7ea4ea1..b7ac375 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -9,6 +9,7 @@
 #include <mw/error.hpp>
 
 #include "asset_store.h"
+#include "authorization.h"
 #include "data.h"
 #include "image_processor.h"
 #include "markdown_renderer.h"
@@ -40,6 +41,9 @@ struct CreateCardInput
 
     /// Optional browser-rendered thumbnail for a foil card.
     std::optional<std::filesystem::path> thumbnail;
+
+    /// Whether the request explicitly supplied a rarity field.
+    bool rarity_was_submitted = true;
 };
 
 /// Create-card input retained as the loose-card handler's explicit name.
@@ -98,6 +102,9 @@ struct UpdateLooseCardInput
 
     /// Browser-rendered thumbnail for a changed resulting foil card.
     std::optional<std::filesystem::path> thumbnail;
+
+    /// Whether the request explicitly supplied a rarity field.
+    bool rarity_was_submitted = true;
 };
 
 /// Coordinate image processing, persistence, and asset publication.
@@ -111,32 +118,38 @@ public:
         ImageProcessor image_processor,
         AssetStore asset_store);
 
-    /// Create a loose card and return its canonical public ID.
-    mw::E<std::string> createLooseCard(CreateLooseCardInput input);
+    /// Create a loose card after re-reading and authorizing its actor.
+    mw::E<std::string> createLooseCard(
+        std::int64_t actor_user_id, CreateLooseCardInput input);
 
-    /// Create a compiled-game card with validated metadata and memberships.
+    /// Create a game card after re-reading and authorizing its actor.
     mw::E<std::string> createGameCard(
+        std::int64_t actor_user_id,
         CreateCardInput input,
         const GameDefinition& game,
         const GameCardMetadata& metadata,
         const std::vector<std::int64_t>& series_ids);
 
-    /// Update a loose card and return its unchanged canonical public ID.
-    mw::E<std::string> updateLooseCard(UpdateLooseCardInput input);
+    /// Update a loose card after re-reading role and authorship.
+    mw::E<std::string> updateLooseCard(
+        std::int64_t actor_user_id, UpdateLooseCardInput input);
 
-    /// Update a compiled-game card and its validated metadata/memberships.
+    /// Update a game card after re-reading role and authorship.
     mw::E<std::string> updateGameCard(
+        std::int64_t actor_user_id,
         UpdateLooseCardInput input,
         const GameDefinition& game,
         const GameCardMetadata& metadata,
         const std::vector<std::int64_t>& series_ids);
 
-    /// Delete a card and its published assets.
-    mw::E<void> deleteCard(const Card& card);
+    /// Delete a card only after re-reading an administrator actor.
+    mw::E<void> deleteCard(
+        std::int64_t actor_user_id, const Card& card);
 
 private:
     /// Create a loose or compiled-game card through the common pipeline.
     mw::E<std::string> createCard(
+        std::int64_t actor_user_id,
         CreateCardInput input,
         const GameDefinition* game,
         const GameCardMetadata* metadata,
@@ -144,6 +157,7 @@ private:
 
     /// Update a loose or compiled-game card through the common pipeline.
     mw::E<std::string> updateCard(
+        std::int64_t actor_user_id,
         UpdateLooseCardInput input,
         const GameDefinition* game,
         const GameCardMetadata* metadata,
@@ -154,4 +168,5 @@ private:
     ImageProcessor image_processor_;
     AssetStore asset_store_;
     MarkdownRenderer markdown_renderer_;
+    AuthorizationService authorization_;
 };
diff --git a/src/clock.cpp b/src/clock.cpp
new file mode 100644
index 0000000..c0fa0e4
--- /dev/null
+++ b/src/clock.cpp
@@ -0,0 +1,14 @@
+#include "clock.h"
+
+#include <chrono>
+
+std::chrono::system_clock::time_point SystemClock::now() const
+{
+    return std::chrono::system_clock::now();
+}
+
+std::int64_t utcDay(std::chrono::system_clock::time_point time)
+{
+    return std::chrono::floor<std::chrono::days>(time)
+        .time_since_epoch().count();
+}
diff --git a/src/clock.h b/src/clock.h
new file mode 100644
index 0000000..b4ef0c6
--- /dev/null
+++ b/src/clock.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <chrono>
+#include <cstdint>
+
+/// Injectable source of wall-clock time.
+class ClockInterface
+{
+public:
+    virtual ~ClockInterface() = default;
+
+    /// Return the current system-clock instant.
+    virtual std::chrono::system_clock::time_point now() const = 0;
+};
+
+/// Production system wall clock.
+class SystemClock final : public ClockInterface
+{
+public:
+    /// Return the current system-clock instant.
+    std::chrono::system_clock::time_point now() const override;
+};
+
+/// Return the signed UTC epoch day containing an instant.
+std::int64_t utcDay(std::chrono::system_clock::time_point time);
diff --git a/src/collection.cpp b/src/collection.cpp
new file mode 100644
index 0000000..997f4da
--- /dev/null
+++ b/src/collection.cpp
@@ -0,0 +1,153 @@
+#include "collection.h"
+
+#include <algorithm>
+#include <chrono>
+#include <cstdint>
+#include <limits>
+#include <utility>
+
+CollectionService::CollectionService(
+    DataSourceInterface& data_source,
+    ClockInterface& clock,
+    mw::CryptoInterface& crypto,
+    std::uint32_t maximum_accumulated_pulls)
+    : data_source_(data_source),
+      clock_(clock),
+      crypto_(crypto),
+      maximum_accumulated_pulls_(maximum_accumulated_pulls)
+{}
+
+std::pair<std::uint32_t, std::int64_t>
+CollectionService::effectivePullState(
+    const User& user, std::int64_t current_day) const
+{
+    const std::int64_t elapsed = std::max<std::int64_t>(
+        0, current_day - user.pull_refresh_day);
+    const std::uint64_t available = std::min<std::uint64_t>(
+        maximum_accumulated_pulls_,
+        static_cast<std::uint64_t>(user.stored_pulls) +
+            static_cast<std::uint64_t>(elapsed));
+    return {
+        static_cast<std::uint32_t>(available),
+        std::max(current_day, user.pull_refresh_day)};
+}
+
+mw::E<User> CollectionService::refresh(std::int64_t user_id)
+{
+    const std::int64_t current_day = utcDay(clock_.now());
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto user = (*transaction)->getUserForUpdate(user_id);
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    if(!*user)
+    {
+        return std::unexpected(mw::httpError(404, "User not found"));
+    }
+    if(!(**user).username)
+    {
+        return std::unexpected(mw::httpError(
+            409, "Username onboarding is required"));
+    }
+    const auto [available, refresh_day] = effectivePullState(
+        **user, current_day);
+    auto updated = (*transaction)->updatePullState(
+        user_id, available, refresh_day);
+    if(!updated)
+    {
+        return std::unexpected(std::move(updated.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    (**user).stored_pulls = available;
+    (**user).pull_refresh_day = refresh_day;
+    return std::move(**user);
+}
+
+mw::E<PullResult> CollectionService::pull(std::int64_t user_id)
+{
+    const std::int64_t current_day = utcDay(clock_.now());
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto user = (*transaction)->getUserForUpdate(user_id);
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    if(!*user)
+    {
+        return std::unexpected(mw::httpError(404, "User not found"));
+    }
+    if(!(**user).username)
+    {
+        return std::unexpected(mw::httpError(
+            409, "Username onboarding is required"));
+    }
+    const auto [available, refresh_day] = effectivePullState(
+        **user, current_day);
+    auto refreshed = (*transaction)->updatePullState(
+        user_id, available, refresh_day);
+    if(!refreshed)
+    {
+        return std::unexpected(std::move(refreshed.error()));
+    }
+    if(available == 0)
+    {
+        auto committed = (*transaction)->commit();
+        if(!committed)
+        {
+            return std::unexpected(std::move(committed.error()));
+        }
+        return std::unexpected(mw::httpError(
+            409, "No pulls are available"));
+    }
+    auto cards = (*transaction)->getPoolCardsForUpdate();
+    if(!cards)
+    {
+        return std::unexpected(std::move(cards.error()));
+    }
+    auto entries = card_pool_.calculate(*cards);
+    if(entries.empty())
+    {
+        auto committed = (*transaction)->commit();
+        if(!committed)
+        {
+            return std::unexpected(std::move(committed.error()));
+        }
+        return std::unexpected(mw::httpError(409, "Card pool is empty"));
+    }
+    auto selected = card_pool_.select(entries, crypto_);
+    if(!selected)
+    {
+        return std::unexpected(std::move(selected.error()));
+    }
+    auto decremented = (*transaction)->updatePullState(
+        user_id, available - 1, refresh_day);
+    if(!decremented)
+    {
+        return std::unexpected(std::move(decremented.error()));
+    }
+    auto quantity = (*transaction)->incrementHolding(
+        user_id, selected->card.id);
+    if(!quantity)
+    {
+        return std::unexpected(std::move(quantity.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return PullResult{std::move(selected->card), *quantity};
+}
diff --git a/src/collection.h b/src/collection.h
new file mode 100644
index 0000000..e9a37b4
--- /dev/null
+++ b/src/collection.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include <cstdint>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+
+#include "card_pool.h"
+#include "clock.h"
+#include "data.h"
+
+/// Result of one successful atomic card pull.
+struct PullResult
+{
+    /// Selected current card.
+    Card card;
+
+    /// User's quantity after the award.
+    std::int64_t quantity;
+};
+
+/// Own lazy pull accrual and atomic collection awards.
+class CollectionService
+{
+public:
+    /// Construct the service over injected time, randomness, and persistence.
+    CollectionService(
+        DataSourceInterface& data_source,
+        ClockInterface& clock,
+        mw::CryptoInterface& crypto,
+        std::uint32_t maximum_accumulated_pulls);
+
+    /// Refresh and persist one user's available pulls.
+    mw::E<User> refresh(std::int64_t user_id);
+
+    /// Consume one pull and increment the selected holding atomically.
+    mw::E<PullResult> pull(std::int64_t user_id);
+
+private:
+    /// Calculate availability and nondecreasing refresh day.
+    std::pair<std::uint32_t, std::int64_t> effectivePullState(
+        const User& user, std::int64_t current_day) const;
+
+    DataSourceInterface& data_source_;
+    ClockInterface& clock_;
+    mw::CryptoInterface& crypto_;
+    std::uint32_t maximum_accumulated_pulls_;
+    CardPoolService card_pool_;
+};
diff --git a/src/config.cpp b/src/config.cpp
index c06b8ac..c8ed81c 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -1,6 +1,7 @@
 #include "config.h"
 
 #include <array>
+#include <cstdlib>
 #include <cstdint>
 #include <filesystem>
 #include <optional>
@@ -9,23 +10,39 @@
 #include <string_view>
 #include <system_error>
 #include <utility>
+#include <variant>
 
 #include <toml++/toml.hpp>
 
+#include "email_address.h"
+
 namespace
 {
 
 const std::set<std::string> CONFIG_KEYS = {
+    "administrator_email",
     "avif_quality",
     "base_url",
     "card_storage_root",
     "database_path",
+    "email",
     "listen_address",
     "listen_port",
+    "maximum_accumulated_pulls",
     "static_root",
     "thumbnail_long_side",
 };
 
+const std::set<std::string> EMAIL_CONFIG_KEYS = {
+    "daily_attempt_limit",
+    "from_address",
+    "from_name",
+    "link_file",
+    "mailjet_api_key_environment",
+    "mailjet_secret_key_environment",
+    "transport",
+};
+
 mw::E<std::string> requiredString(
     const toml::table& table,
     std::string_view name)
@@ -124,6 +141,23 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
         }
     }
 
+    const toml::table* email_table = table["email"].as_table();
+    if(email_table == nullptr)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Configuration key 'email' must be a table"));
+    }
+    for(const auto& [key, value] : *email_table)
+    {
+        [[maybe_unused]] const toml::node& node = value;
+        if(!EMAIL_CONFIG_KEYS.contains(std::string(key.str())))
+        {
+            return std::unexpected(mw::runtimeError(
+                "Unknown email configuration key '" +
+                std::string(key.str()) + "'"));
+        }
+    }
+
     auto base_url_text = requiredString(table, "base_url");
     auto listen_address_text = requiredString(table, "listen_address");
     auto static_root_text = requiredString(table, "static_root");
@@ -132,9 +166,14 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
     auto avif_quality = requiredInteger(table, "avif_quality");
     auto thumbnail_long_side = requiredInteger(
         table, "thumbnail_long_side");
+    auto administrator_email_text = requiredString(
+        table, "administrator_email");
+    auto maximum_accumulated_pulls = requiredInteger(
+        table, "maximum_accumulated_pulls");
     if(!base_url_text || !listen_address_text || !static_root_text ||
        !database_path_text || !card_storage_text || !avif_quality ||
-       !thumbnail_long_side)
+       !thumbnail_long_side || !administrator_email_text ||
+       !maximum_accumulated_pulls)
     {
         if(!base_url_text)
         {
@@ -160,7 +199,18 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
         {
             return std::unexpected(std::move(avif_quality.error()));
         }
-        return std::unexpected(std::move(thumbnail_long_side.error()));
+        if(!thumbnail_long_side)
+        {
+            return std::unexpected(std::move(
+                thumbnail_long_side.error()));
+        }
+        if(!administrator_email_text)
+        {
+            return std::unexpected(std::move(
+                administrator_email_text.error()));
+        }
+        return std::unexpected(std::move(
+            maximum_accumulated_pulls.error()));
     }
 
     auto base_url = mw::URL::fromStr(*base_url_text);
@@ -219,6 +269,18 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
         return std::unexpected(mw::runtimeError(
             "thumbnail_long_side must be a positive 32-bit integer"));
     }
+    if(*maximum_accumulated_pulls < 1 ||
+       *maximum_accumulated_pulls > UINT32_MAX)
+    {
+        return std::unexpected(mw::runtimeError(
+            "maximum_accumulated_pulls must be a positive 32-bit integer"));
+    }
+    auto administrator_email = normalizeEmail(*administrator_email_text);
+    if(!administrator_email)
+    {
+        return std::unexpected(mw::runtimeError(
+            "administrator_email is invalid"));
+    }
 
     const std::filesystem::path config_directory =
         std::filesystem::absolute(config_path).parent_path();
@@ -228,6 +290,106 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
         config_directory, *database_path_text);
     const std::filesystem::path card_storage_root = normalizedPath(
         config_directory, *card_storage_text);
+
+    auto transport_text = requiredString(*email_table, "transport");
+    if(!transport_text)
+    {
+        return std::unexpected(std::move(transport_text.error()));
+    }
+    EmailConfig email;
+    if(*transport_text == "file")
+    {
+        auto link_file = requiredString(*email_table, "link_file");
+        if(!link_file)
+        {
+            return std::unexpected(std::move(link_file.error()));
+        }
+        email.transport = EmailTransport::FILE;
+        email.link_file = std::filesystem::path(*link_file);
+        if(!email.link_file.is_absolute())
+        {
+            return std::unexpected(mw::runtimeError(
+                "email.link_file must be absolute with an existing parent"));
+        }
+        email.link_file = std::filesystem::absolute(
+            email.link_file).lexically_normal();
+        if(!std::filesystem::is_directory(email.link_file.parent_path()))
+        {
+            return std::unexpected(mw::runtimeError(
+                "email.link_file must be absolute with an existing parent"));
+        }
+        if(pathContains(static_root, email.link_file) ||
+           pathContains(card_storage_root, email.link_file))
+        {
+            return std::unexpected(mw::runtimeError(
+                "email.link_file must be outside public asset roots"));
+        }
+        if(email_table->contains("mailjet_api_key_environment") ||
+           email_table->contains("mailjet_secret_key_environment") ||
+           email_table->contains("daily_attempt_limit") ||
+           email_table->contains("from_address") ||
+           email_table->contains("from_name"))
+        {
+            return std::unexpected(mw::runtimeError(
+                "File email transport rejects Mailjet settings"));
+        }
+        if(base_url->scheme() == "http" &&
+           std::holds_alternative<mw::IPSocketInfo>(*listen_address))
+        {
+            const std::string& address =
+                std::get<mw::IPSocketInfo>(*listen_address).address;
+            if(address != "127.0.0.1" && address != "::1" &&
+               address != "localhost")
+            {
+                return std::unexpected(mw::runtimeError(
+                    "HTTP file transport requires a loopback listener"));
+            }
+        }
+    }
+    else if(*transport_text == "mailjet")
+    {
+        auto from_address = requiredString(*email_table, "from_address");
+        auto from_name = requiredString(*email_table, "from_name");
+        auto api_environment = requiredString(
+            *email_table, "mailjet_api_key_environment");
+        auto secret_environment = requiredString(
+            *email_table, "mailjet_secret_key_environment");
+        auto daily_limit = requiredInteger(
+            *email_table, "daily_attempt_limit");
+        if(!from_address || !from_name || !api_environment ||
+           !secret_environment || !daily_limit || from_name->empty() ||
+           api_environment->empty() || secret_environment->empty() ||
+           *daily_limit < 1 ||
+           *daily_limit > UINT32_MAX || base_url->scheme() != "https")
+        {
+            return std::unexpected(mw::runtimeError(
+                "Mailjet email configuration is invalid"));
+        }
+        if(!normalizeEmail(*from_address))
+        {
+            return std::unexpected(mw::runtimeError(
+                "email.from_address is invalid"));
+        }
+        const char* api_key = std::getenv(api_environment->c_str());
+        const char* secret_key = std::getenv(secret_environment->c_str());
+        if(api_key == nullptr || *api_key == '\0' ||
+           secret_key == nullptr || *secret_key == '\0')
+        {
+            return std::unexpected(mw::runtimeError(
+                "Mailjet credential environment variables are missing"));
+        }
+        email.transport = EmailTransport::MAILJET;
+        email.from_address = std::move(*from_address);
+        email.from_name = std::move(*from_name);
+        email.mailjet_api_key_environment = std::move(*api_environment);
+        email.mailjet_secret_key_environment = std::move(*secret_environment);
+        email.daily_attempt_limit = static_cast<std::uint32_t>(*daily_limit);
+    }
+    else
+    {
+        return std::unexpected(mw::runtimeError(
+            "email.transport must be 'mailjet' or 'file'"));
+    }
     std::error_code filesystem_error;
     if(!std::filesystem::is_directory(static_root, filesystem_error) ||
        filesystem_error)
@@ -247,6 +409,13 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
         return std::unexpected(mw::runtimeError(
             "database_path must not be inside a mounted asset root"));
     }
+    if(email.transport == EmailTransport::FILE &&
+       (pathContains(static_root, email.link_file) ||
+        pathContains(card_storage_root, email.link_file)))
+    {
+        return std::unexpected(mw::runtimeError(
+            "email.link_file must not be inside an asset root"));
+    }
 
     auto database_directory = createDirectory(
         database_path.parent_path(), "the database directory");
@@ -275,5 +444,9 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
         card_storage_root,
         static_cast<int>(*avif_quality),
         static_cast<std::uint32_t>(*thumbnail_long_side),
+        std::move(administrator_email->email),
+        std::move(administrator_email->key),
+        static_cast<std::uint32_t>(*maximum_accumulated_pulls),
+        std::move(email),
     };
 }
diff --git a/src/config.h b/src/config.h
index 89174fd..f7c371e 100644
--- a/src/config.h
+++ b/src/config.h
@@ -2,11 +2,44 @@
 
 #include <cstdint>
 #include <filesystem>
+#include <string>
 
 #include <mw/http_server.hpp>
 #include <mw/error.hpp>
 #include <mw/url.hpp>
 
+/// Supported authentication email delivery mechanisms.
+enum class EmailTransport
+{
+    MAILJET,
+    FILE
+};
+
+/// Validated authentication email configuration.
+struct EmailConfig
+{
+    /// Selected delivery mechanism.
+    EmailTransport transport = EmailTransport::FILE;
+
+    /// Sender address used by Mailjet.
+    std::string from_address;
+
+    /// Human-readable sender name used by Mailjet.
+    std::string from_name;
+
+    /// Environment variable containing the Mailjet API key.
+    std::string mailjet_api_key_environment;
+
+    /// Environment variable containing the Mailjet secret key.
+    std::string mailjet_secret_key_environment;
+
+    /// Persistent daily ceiling for attempted Mailjet calls.
+    std::uint32_t daily_attempt_limit = 180;
+
+    /// Private file receiving the latest development link.
+    std::filesystem::path link_file;
+};
+
 /// Validated process configuration.
 struct Config
 {
@@ -30,6 +63,18 @@ struct Config
 
     /// Long-side pixel count used for generated thumbnails.
     std::uint32_t thumbnail_long_side;
+
+    /// Validated administrator email spelling.
+    std::string administrator_email = "admin@example.com";
+
+    /// Normalized immutable administrator identity key.
+    std::string administrator_email_key = "admin@example.com";
+
+    /// Maximum lazily accumulated pulls; lowering it can discard accrual.
+    std::uint32_t maximum_accumulated_pulls = 3;
+
+    /// Authentication email delivery configuration.
+    EmailConfig email;
 };
 
 /// Load, validate, and normalize one TOML process configuration.
diff --git a/src/data.cpp b/src/data.cpp
index 5a343c0..b1c97f5 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -26,6 +26,232 @@ mw::Error migrationVersionError(
 
 } // namespace
 
+mw::E<std::optional<User>>
+DataSourceTransactionInterface::getUserForUpdate(
+    [[maybe_unused]] std::int64_t user_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "User transactions are unavailable"));
+}
+
+mw::E<std::optional<User>>
+DataSourceTransactionInterface::getUserByEmailKeyForUpdate(
+    [[maybe_unused]] const std::string& email_key)
+{
+    return std::unexpected(mw::runtimeError(
+        "User transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::userOwnsCardForUpdate(
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] std::int64_t card_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection transactions are unavailable"));
+}
+
+mw::E<std::vector<Card>>
+DataSourceTransactionInterface::getPoolCardsForUpdate()
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::insertUser(
+    [[maybe_unused]] const User& user)
+{
+    return std::unexpected(mw::runtimeError(
+        "User transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::updateUsername(
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] const std::string& username,
+    [[maybe_unused]] const std::string& username_key)
+{
+    return std::unexpected(mw::runtimeError(
+        "User transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::promoteUser(
+    [[maybe_unused]] std::int64_t user_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "User transactions are unavailable"));
+}
+
+mw::E<AuthenticationReservation>
+DataSourceTransactionInterface::reserveAuthenticationEmail(
+    [[maybe_unused]] const std::string& email_key,
+    [[maybe_unused]] std::int64_t now,
+    [[maybe_unused]] bool use_global_quota,
+    [[maybe_unused]] std::int64_t utc_day,
+    [[maybe_unused]] std::uint32_t daily_limit)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<std::int64_t>
+DataSourceTransactionInterface::insertAuthenticationChallenge(
+    [[maybe_unused]] const std::string& email,
+    [[maybe_unused]] const std::string& email_key,
+    [[maybe_unused]] const TokenHash& token_hash,
+    [[maybe_unused]] std::int64_t created_at,
+    [[maybe_unused]] std::int64_t expires_at)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<bool>
+DataSourceTransactionInterface::markAuthenticationChallengeDelivered(
+    [[maybe_unused]] std::int64_t challenge_id,
+    [[maybe_unused]] std::int64_t delivered_at)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<void>
+DataSourceTransactionInterface::deleteAuthenticationChallenge(
+    [[maybe_unused]] std::int64_t challenge_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+DataSourceTransactionInterface::consumeAuthenticationChallenge(
+    [[maybe_unused]] const TokenHash& token_hash,
+    [[maybe_unused]] std::int64_t now)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<void>
+DataSourceTransactionInterface::invalidateAuthenticationChallenges(
+    [[maybe_unused]] const std::string& email_key,
+    [[maybe_unused]] std::int64_t except_challenge_id,
+    [[maybe_unused]] std::int64_t now)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::insertSession(
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] const TokenHash& token_hash,
+    [[maybe_unused]] const std::string& csrf_token,
+    [[maybe_unused]] std::int64_t created_at,
+    [[maybe_unused]] std::int64_t expires_at)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::deleteSession(
+    [[maybe_unused]] const TokenHash& token_hash)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updatePullState(
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] std::uint32_t stored_pulls,
+    [[maybe_unused]] std::int64_t refresh_day)
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::incrementHolding(
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] std::int64_t card_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection transactions are unavailable"));
+}
+
+mw::E<std::vector<Card>> DataSourceInterface::getCardsByCreator(
+    [[maybe_unused]] std::int64_t creator_user_id) const
+{
+    return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::vector<Card>> DataSourceInterface::getPoolCards() const
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection data is unavailable"));
+}
+
+mw::E<std::optional<User>> DataSourceInterface::getUser(
+    [[maybe_unused]] std::int64_t user_id) const
+{
+    return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::optional<User>> DataSourceInterface::getUserByEmailKey(
+    [[maybe_unused]] const std::string& email_key) const
+{
+    return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::vector<User>> DataSourceInterface::getUsers() const
+{
+    return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::optional<SessionContext>> DataSourceInterface::getSession(
+    [[maybe_unused]] const TokenHash& token_hash,
+    [[maybe_unused]] std::int64_t now) const
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication data is unavailable"));
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+DataSourceInterface::getAuthenticationChallenge(
+    [[maybe_unused]] const TokenHash& token_hash,
+    [[maybe_unused]] std::int64_t now) const
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication data is unavailable"));
+}
+
+mw::E<std::vector<CollectionEntry>> DataSourceInterface::getCollection(
+    [[maybe_unused]] std::int64_t user_id) const
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection data is unavailable"));
+}
+
+mw::E<bool> DataSourceInterface::userOwnsCard(
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] std::int64_t card_id) const
+{
+    return std::unexpected(mw::runtimeError(
+        "Collection data is unavailable"));
+}
+
+mw::E<User> DataSourceInterface::reconcileAdministrator(
+    [[maybe_unused]] const std::string& email,
+    [[maybe_unused]] const std::string& email_key,
+    [[maybe_unused]] std::int64_t created_at,
+    [[maybe_unused]] std::int64_t pull_refresh_day)
+{
+    return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<void> DataSourceInterface::cleanupAuthentication(
+    [[maybe_unused]] std::int64_t now)
+{
+    return std::unexpected(mw::runtimeError(
+        "Authentication data is unavailable"));
+}
+
 mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
 {
     auto version_result = getSchemaVersion();
diff --git a/src/data.h b/src/data.h
index bd397a5..110c8f6 100644
--- a/src/data.h
+++ b/src/data.h
@@ -11,6 +11,70 @@
 #include "card.h"
 #include "game.h"
 #include "game_definition.h"
+#include "user.h"
+
+/// Raw SHA-256 credential digest stored as a 32-byte SQLite BLOB.
+using TokenHash = std::vector<unsigned char>;
+
+/// One distinct card and the quantity owned by a user.
+struct CollectionEntry
+{
+    /// Current card presentation.
+    Card card;
+
+    /// Positive quantity owned.
+    std::int64_t quantity;
+};
+
+/// Delivered, unconsumed authentication challenge metadata.
+struct AuthenticationChallenge
+{
+    /// Internal challenge identity.
+    std::int64_t id;
+
+    /// Validated delivery spelling.
+    std::string email;
+
+    /// Normalized account identity.
+    std::string email_key;
+
+    /// Unix expiry timestamp.
+    std::int64_t expires_at;
+};
+
+/// Valid session joined with its current user record.
+struct SessionContext
+{
+    /// Internal session identity.
+    std::int64_t session_id;
+
+    /// Current user record.
+    User user;
+
+    /// Independent authenticated-form CSRF credential.
+    std::string csrf_token;
+
+    /// Non-sliding Unix expiry timestamp.
+    std::int64_t expires_at;
+};
+
+/// Result of atomically reserving authentication email capacity.
+enum class AuthenticationReservationStatus
+{
+    RESERVED,
+    EMAIL_LIMITED,
+    GLOBAL_LIMITED
+};
+
+/// Reservation status and retry delay for a rejected request.
+struct AuthenticationReservation
+{
+    /// Atomic reservation result.
+    AuthenticationReservationStatus status;
+
+    /// Seconds until another attempt may succeed.
+    std::int64_t retry_after;
+};
 
 /// Immutable collection of compiled game definitions.
 class GameRegistry;
@@ -36,10 +100,93 @@ public:
     /// Return whether a loose-card number already exists.
     virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;
 
+    /// Re-read a user while the transaction lock is held.
+    virtual mw::E<std::optional<User>>
+    getUserForUpdate(std::int64_t user_id);
+
+    /// Re-read a user by normalized email while the lock is held.
+    virtual mw::E<std::optional<User>> getUserByEmailKeyForUpdate(
+        const std::string& email_key);
+
     /// Re-read a card while the transaction lock is held.
     virtual mw::E<std::optional<Card>>
     getCardForUpdate(std::int64_t card_id) = 0;
 
+    /// Return whether a user owns a card while the lock is held.
+    virtual mw::E<bool> userOwnsCardForUpdate(
+        std::int64_t user_id, std::int64_t card_id);
+
+    /// Return the positive-rarity pool in card-ID order under the lock.
+    virtual mw::E<std::vector<Card>> getPoolCardsForUpdate();
+
+    /// Insert a newly confirmed account and return its internal ID.
+    virtual mw::E<std::int64_t> insertUser(const User& user);
+
+    /// Atomically replace a user's normalized username pair.
+    virtual mw::E<bool> updateUsername(
+        std::int64_t user_id,
+        const std::string& username,
+        const std::string& username_key);
+
+    /// Conditionally promote one player to creator.
+    virtual mw::E<bool> promoteUser(std::int64_t user_id);
+
+    /// Reserve per-email and optional global delivery capacity.
+    virtual mw::E<AuthenticationReservation> reserveAuthenticationEmail(
+        const std::string& email_key,
+        std::int64_t now,
+        bool use_global_quota,
+        std::int64_t utc_day,
+        std::uint32_t daily_limit);
+
+    /// Insert a pending authentication challenge and return its ID.
+    virtual mw::E<std::int64_t> insertAuthenticationChallenge(
+        const std::string& email,
+        const std::string& email_key,
+        const TokenHash& token_hash,
+        std::int64_t created_at,
+        std::int64_t expires_at);
+
+    /// Activate a pending challenge after successful delivery.
+    virtual mw::E<bool> markAuthenticationChallengeDelivered(
+        std::int64_t challenge_id, std::int64_t delivered_at);
+
+    /// Delete one challenge after failed delivery.
+    virtual mw::E<void> deleteAuthenticationChallenge(
+        std::int64_t challenge_id);
+
+    /// Atomically consume one valid delivered challenge.
+    virtual mw::E<std::optional<AuthenticationChallenge>>
+    consumeAuthenticationChallenge(
+        const TokenHash& token_hash, std::int64_t now);
+
+    /// Invalidate every other outstanding challenge for an email key.
+    virtual mw::E<void> invalidateAuthenticationChallenges(
+        const std::string& email_key,
+        std::int64_t except_challenge_id,
+        std::int64_t now);
+
+    /// Insert a four-week session and return its internal ID.
+    virtual mw::E<std::int64_t> insertSession(
+        std::int64_t user_id,
+        const TokenHash& token_hash,
+        const std::string& csrf_token,
+        std::int64_t created_at,
+        std::int64_t expires_at);
+
+    /// Delete a session by its token digest.
+    virtual mw::E<void> deleteSession(const TokenHash& token_hash);
+
+    /// Persist a lazily refreshed pull state.
+    virtual mw::E<void> updatePullState(
+        std::int64_t user_id,
+        std::uint32_t stored_pulls,
+        std::int64_t refresh_day);
+
+    /// Insert or increment a holding and return its new quantity.
+    virtual mw::E<std::int64_t> incrementHolding(
+        std::int64_t user_id, std::int64_t card_id);
+
     /// Insert common, game-specific, and series-membership card rows.
     virtual mw::E<std::int64_t> insertCard(
         const Card& card,
@@ -94,10 +241,55 @@ public:
     /// Return all cards for the unpaginated index.
     virtual mw::E<std::vector<Card>> getCards() const = 0;
 
+    /// Return all cards authored by one user.
+    virtual mw::E<std::vector<Card>> getCardsByCreator(
+        std::int64_t creator_user_id) const;
+
+    /// Return every current positive-rarity card in card-ID order.
+    virtual mw::E<std::vector<Card>> getPoolCards() const;
+
     /// Return a card by its parsed identity.
     virtual mw::E<std::optional<Card>>
     getCard(const CardIdentity& identity) const = 0;
 
+    /// Return a user by internal identity.
+    virtual mw::E<std::optional<User>> getUser(
+        std::int64_t user_id) const;
+
+    /// Return a user by normalized immutable email identity.
+    virtual mw::E<std::optional<User>> getUserByEmailKey(
+        const std::string& email_key) const;
+
+    /// Return every user for administrator management.
+    virtual mw::E<std::vector<User>> getUsers() const;
+
+    /// Return a valid session joined with its current user.
+    virtual mw::E<std::optional<SessionContext>> getSession(
+        const TokenHash& token_hash, std::int64_t now) const;
+
+    /// Read one valid delivered challenge without consuming it.
+    virtual mw::E<std::optional<AuthenticationChallenge>>
+    getAuthenticationChallenge(
+        const TokenHash& token_hash, std::int64_t now) const;
+
+    /// Return a user's distinct collection entries.
+    virtual mw::E<std::vector<CollectionEntry>> getCollection(
+        std::int64_t user_id) const;
+
+    /// Return whether a user currently owns a card.
+    virtual mw::E<bool> userOwnsCard(
+        std::int64_t user_id, std::int64_t card_id) const;
+
+    /// Reconcile the one immutable configured administrator identity.
+    virtual mw::E<User> reconcileAdministrator(
+        const std::string& email,
+        const std::string& email_key,
+        std::int64_t created_at,
+        std::int64_t pull_refresh_day);
+
+    /// Best-effort removal of obsolete authentication persistence rows.
+    virtual mw::E<void> cleanupAuthentication(std::int64_t now);
+
     /// Return a card's game-owned display fields.
     virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
         const GameDefinition& game,
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index ae48098..030165f 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -1,5 +1,6 @@
 #include "data_sqlite.h"
 
+#include <algorithm>
 #include <array>
 #include <cstdint>
 #include <limits>
@@ -33,10 +34,88 @@ mw::E<void> rollbackWithError(
     return std::unexpected(std::move(error));
 }
 
-const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
+const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
+    R"sql(
+        CREATE TABLE application_metadata(
+            key TEXT PRIMARY KEY,
+            value TEXT NOT NULL
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE TABLE users(
+            id INTEGER PRIMARY KEY,
+            email TEXT NOT NULL,
+            email_key TEXT NOT NULL UNIQUE,
+            username TEXT,
+            username_key TEXT UNIQUE,
+            role INTEGER NOT NULL CHECK(role BETWEEN 0 AND 2),
+            stored_pulls INTEGER NOT NULL CHECK(stored_pulls >= 0),
+            pull_refresh_day INTEGER NOT NULL,
+            created_at INTEGER NOT NULL,
+            CHECK((username IS NULL) = (username_key IS NULL))
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE UNIQUE INDEX users_one_administrator
+        ON users(role) WHERE role = 2;
+    )sql",
+    R"sql(
+        CREATE TABLE authentication_challenges(
+            id INTEGER PRIMARY KEY,
+            email TEXT NOT NULL,
+            email_key TEXT NOT NULL,
+            token_hash BLOB NOT NULL UNIQUE
+                CHECK(length(token_hash) = 32),
+            created_at INTEGER NOT NULL,
+            expires_at INTEGER NOT NULL,
+            delivered_at INTEGER,
+            consumed_at INTEGER,
+            CHECK(expires_at > created_at),
+            CHECK(delivered_at IS NULL OR delivered_at >= created_at),
+            CHECK(consumed_at IS NULL OR delivered_at IS NOT NULL)
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE INDEX authentication_challenges_email
+        ON authentication_challenges(email_key, expires_at);
+    )sql",
+    R"sql(
+        CREATE TABLE sessions(
+            id INTEGER PRIMARY KEY,
+            user_id INTEGER NOT NULL
+                REFERENCES users(id) ON DELETE CASCADE,
+            token_hash BLOB NOT NULL UNIQUE
+                CHECK(length(token_hash) = 32),
+            csrf_token TEXT NOT NULL UNIQUE,
+            created_at INTEGER NOT NULL,
+            expires_at INTEGER NOT NULL,
+            CHECK(expires_at > created_at)
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE INDEX sessions_user ON sessions(user_id);
+    )sql",
+    R"sql(
+        CREATE INDEX sessions_expiry ON sessions(expires_at);
+    )sql",
+    R"sql(
+        CREATE TABLE authentication_email_limits(
+            email_key TEXT PRIMARY KEY,
+            next_allowed_at INTEGER NOT NULL
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE TABLE authentication_quota(
+            utc_day INTEGER PRIMARY KEY,
+            attempted_sends INTEGER NOT NULL
+                CHECK(attempted_sends >= 0)
+        ) STRICT;
+    )sql",
     R"sql(
         CREATE TABLE cards (
             id INTEGER PRIMARY KEY,
+            creator_user_id INTEGER NOT NULL
+                REFERENCES users(id) ON DELETE RESTRICT,
             game_short_name TEXT,
             card_number INTEGER NOT NULL,
             name TEXT NOT NULL,
@@ -71,6 +150,12 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
         ON cards(card_number)
         WHERE game_short_name IS NULL;
     )sql",
+    R"sql(
+        CREATE INDEX cards_creator ON cards(creator_user_id, id);
+    )sql",
+    R"sql(
+        CREATE INDEX cards_pool ON cards(rarity, id) WHERE rarity > 0;
+    )sql",
     R"sql(
         CREATE TABLE game_sequences (
             game_short_name TEXT PRIMARY KEY,
@@ -115,8 +200,135 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
             END;
         END;
     )sql",
+    R"sql(
+        CREATE TABLE card_holdings(
+            user_id INTEGER NOT NULL
+                REFERENCES users(id) ON DELETE CASCADE,
+            card_id INTEGER NOT NULL
+                REFERENCES cards(id) ON DELETE CASCADE,
+            quantity INTEGER NOT NULL CHECK(quantity > 0),
+            PRIMARY KEY(user_id, card_id)
+        ) WITHOUT ROWID, STRICT;
+    )sql",
 };
 
+mw::E<std::string> tokenBytes(const TokenHash& token_hash)
+{
+    if(token_hash.size() != 32)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Credential digest must contain 32 bytes"));
+    }
+    return std::string(
+        reinterpret_cast<const char*>(token_hash.data()),
+        token_hash.size());
+}
+
+mw::E<UserRole> userRole(std::int64_t value)
+{
+    if(value < static_cast<std::int64_t>(UserRole::PLAYER) ||
+       value > static_cast<std::int64_t>(UserRole::ADMINISTRATOR))
+    {
+        return std::unexpected(mw::runtimeError(
+            "Database contains an invalid user role"));
+    }
+    return static_cast<UserRole>(value);
+}
+
+using UserRow = std::tuple<
+    std::int64_t,
+    std::string,
+    std::string,
+    std::optional<std::string>,
+    std::int64_t,
+    std::int64_t,
+    std::int64_t,
+    std::int64_t>;
+
+mw::E<User> userFromRow(UserRow row)
+{
+    auto [id, email, email_key, username, role_value, stored_pulls,
+          refresh_day, created_at] = std::move(row);
+    auto role = userRole(role_value);
+    if(!role || stored_pulls < 0 ||
+       stored_pulls > std::numeric_limits<std::uint32_t>::max())
+    {
+        return std::unexpected(mw::runtimeError(
+            "Database contains an invalid user record"));
+    }
+    return User{
+        id,
+        std::move(email),
+        std::move(email_key),
+        std::move(username),
+        *role,
+        static_cast<std::uint32_t>(stored_pulls),
+        refresh_day,
+        created_at};
+}
+
+using CardRow = std::tuple<
+    std::int64_t,
+    std::int64_t,
+    std::optional<std::string>,
+    std::int64_t,
+    std::string,
+    std::optional<std::string>,
+    std::optional<std::string>,
+    std::int64_t,
+    std::string,
+    std::optional<std::string>,
+    std::string,
+    std::int64_t>;
+
+mw::E<Card> cardFromRow(CardRow row)
+{
+    auto [id, creator_user_id, game_short_name, card_number, name,
+          short_description, long_description, rarity, front_extension,
+          foil_extension, thumbnail_extension, revision] = std::move(row);
+    const bool invalid_loose_number =
+        !game_short_name &&
+        card_number > std::numeric_limits<std::uint32_t>::max();
+    const bool invalid_game_number =
+        game_short_name &&
+        (game_short_name->empty() || card_number == 0);
+    if(creator_user_id <= 0 || card_number < 0 || invalid_loose_number ||
+       invalid_game_number || rarity < 0 || revision < 1)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Database contains an invalid card record"));
+    }
+    return Card{
+        id,
+        {std::move(game_short_name),
+         static_cast<std::uint64_t>(card_number)},
+        std::move(name),
+        std::move(short_description),
+        std::move(long_description),
+        rarity,
+        std::move(front_extension),
+        std::move(foil_extension),
+        std::move(thumbnail_extension),
+        revision,
+        creator_user_id};
+}
+
+mw::E<std::vector<Card>> cardsFromRows(std::vector<CardRow> rows)
+{
+    std::vector<Card> result;
+    result.reserve(rows.size());
+    for(CardRow& row : rows)
+    {
+        auto card = cardFromRow(std::move(row));
+        if(!card)
+        {
+            return std::unexpected(std::move(card.error()));
+        }
+        result.push_back(std::move(*card));
+    }
+    return result;
+}
+
 class DataSourceSQLiteTransaction final
     : public DataSourceTransactionInterface
 {
@@ -229,327 +441,391 @@ public:
         return *result != 0;
     }
 
-    /// Re-read a card while the transaction lock is held.
-    mw::E<std::optional<Card>> getCardForUpdate(
-        std::int64_t card_id) override
+    /// Re-read a user while the transaction lock is held.
+    mw::E<std::optional<User>> getUserForUpdate(
+        std::int64_t user_id) override
     {
         auto statement = connection_.statementFromStr(
-            "SELECT id, game_short_name, card_number, name, "
-            "short_description, long_description, rarity, "
-            "front_extension, foil_extension, thumbnail_extension, "
-            "revision FROM cards WHERE id = ?;");
+            "SELECT id, email, email_key, username, role, stored_pulls, "
+            "pull_refresh_day, created_at FROM users WHERE id = ?;");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
-        auto bind = statement->bind<std::int64_t>(card_id);
+        auto bind = statement->bind<std::int64_t>(user_id);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
         }
-        auto rows = connection_.eval<
-            std::int64_t,
-            std::optional<std::string>,
-            std::int64_t,
-            std::string,
-            std::optional<std::string>,
-            std::optional<std::string>,
-            std::int64_t,
-            std::string,
-            std::optional<std::string>,
-            std::string,
-            std::int64_t>(std::move(*statement));
-        if(!rows)
+        return readUser(std::move(*statement));
+    }
+
+    /// Re-read a user by normalized email while the lock is held.
+    mw::E<std::optional<User>> getUserByEmailKeyForUpdate(
+        const std::string& email_key) override
+    {
+        auto statement = connection_.statementFromStr(
+            "SELECT id, email, email_key, username, role, stored_pulls, "
+            "pull_refresh_day, created_at FROM users WHERE email_key = ?;");
+        if(!statement)
         {
-            return std::unexpected(std::move(rows.error()));
+            return std::unexpected(std::move(statement.error()));
         }
-        if(rows->empty())
+        auto bind = statement->bind<std::string>(email_key);
+        if(!bind)
         {
-            return std::optional<Card>{};
+            return std::unexpected(std::move(bind.error()));
         }
+        return readUser(std::move(*statement));
+    }
 
-        auto& [
-            id,
-            game_short_name,
-            card_number,
-            name,
-            short_description,
-            long_description,
-            rarity,
-            front_extension,
-            foil_extension,
-            thumbnail_extension,
-            revision] = rows->front();
-        Card card = {
-            id,
-            {std::move(game_short_name),
-             static_cast<std::uint64_t>(card_number)},
-            std::move(name),
-            std::move(short_description),
-            std::move(long_description),
-            rarity,
-            std::move(front_extension),
-            std::move(foil_extension),
-            std::move(thumbnail_extension),
-            revision,
-        };
-        return std::optional<Card>(std::move(card));
+    /// Return whether a user owns a card while the lock is held.
+    mw::E<bool> userOwnsCardForUpdate(
+        std::int64_t user_id, std::int64_t card_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "SELECT EXISTS(SELECT 1 FROM card_holdings "
+            "WHERE user_id = ? AND card_id = ?);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(user_id, card_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto result = connection_.evalToValue<int>(std::move(*statement));
+        if(!result)
+        {
+            return std::unexpected(std::move(result.error()));
+        }
+        return *result != 0;
     }
 
-    /// Insert common and series-membership rows for a loose card.
-    mw::E<std::int64_t> insertCard(
-        const Card& card,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
-        const std::vector<std::int64_t>& series_ids) override
+    /// Return the current positive-rarity pool under the lock.
+    mw::E<std::vector<Card>> getPoolCardsForUpdate() override
     {
-        if(card.id != 0)
+        return readCards(
+            "WHERE rarity > 0 ORDER BY id");
+    }
+
+    /// Insert a newly confirmed account and return its internal ID.
+    mw::E<std::int64_t> insertUser(const User& user) override
+    {
+        if(user.id != 0)
         {
             return std::unexpected(mw::runtimeError(
-                "A new card cannot already have an internal ID"));
+                "A new user cannot already have an internal ID"));
         }
-        if((game == nullptr) != (metadata == nullptr))
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO users(email, email_key, username, username_key, "
+            "role, stored_pulls, pull_refresh_day, created_at) "
+            "VALUES (?, ?, ?, NULL, ?, ?, ?, ?);");
+        if(!statement)
         {
-            return std::unexpected(mw::runtimeError(
-                "Game definition and metadata must be provided together"));
+            return std::unexpected(std::move(statement.error()));
         }
-        if(card.identity.game_short_name)
+        auto bind = statement->bind(
+            user.email,
+            user.email_key,
+            user.username,
+            static_cast<std::int64_t>(user.role),
+            static_cast<std::int64_t>(user.stored_pulls),
+            user.pull_refresh_day,
+            user.created_at);
+        if(!bind)
         {
-            if(game == nullptr)
-            {
-                return std::unexpected(mw::runtimeError(
-                    "A game card requires compiled game metadata"));
-            }
-            if(game->shortName() != *card.identity.game_short_name)
-            {
-                return std::unexpected(mw::runtimeError(
-                    "Card and compiled game identities differ"));
-            }
+            return std::unexpected(std::move(bind.error()));
         }
-        else if(game != nullptr || !series_ids.empty())
+        auto inserted = connection_.execute(std::move(*statement));
+        if(!inserted)
         {
-            return std::unexpected(mw::runtimeError(
-                "A loose card cannot have game metadata or series"));
+            return std::unexpected(std::move(inserted.error()));
         }
-        if(!card.identity.game_short_name &&
-           card.identity.card_number >
-           std::numeric_limits<std::uint32_t>::max())
+        return connection_.lastInsertRowID();
+    }
+
+    /// Atomically replace a user's normalized username pair.
+    mw::E<bool> updateUsername(
+        std::int64_t user_id,
+        const std::string& username,
+        const std::string& username_key) override
+    {
+        auto statement = connection_.statementFromStr(
+            "UPDATE users SET username = ?, username_key = ? "
+            "WHERE id = ? RETURNING id;");
+        if(!statement)
         {
-            return std::unexpected(mw::runtimeError(
-                "Loose card number exceeds the 32-bit namespace"));
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(username, username_key, user_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
         }
+        return !rows->empty();
+    }
 
-        auto insert = connection_.statementFromStr(
-            "INSERT INTO cards ("
-            "game_short_name, card_number, name, short_description, "
-            "long_description, rarity, front_extension, foil_extension, "
-            "thumbnail_extension, revision) "
-            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
-        if(!insert)
+    /// Conditionally promote one player to creator.
+    mw::E<bool> promoteUser(std::int64_t user_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "UPDATE users SET role = 1 WHERE id = ? AND role = 0 "
+            "RETURNING id;");
+        if(!statement)
         {
-            return std::unexpected(std::move(insert.error()));
+            return std::unexpected(std::move(statement.error()));
         }
-        auto bind = insert->bind(
-            card.identity.game_short_name,
-            static_cast<std::int64_t>(card.identity.card_number),
-            card.name,
-            card.short_description,
-            card.long_description,
-            card.rarity,
-            card.front_extension,
-            card.foil_extension,
-            card.thumbnail_extension,
-            card.revision);
+        auto bind = statement->bind<std::int64_t>(user_id);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
         }
-        auto execute = connection_.execute(std::move(*insert));
-        if(!execute)
+        auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+        if(!rows)
         {
-            return std::unexpected(std::move(execute.error()));
+            return std::unexpected(std::move(rows.error()));
         }
-        const std::int64_t card_id = connection_.lastInsertRowID();
+        return !rows->empty();
+    }
 
-        if(game != nullptr)
+    /// Reserve per-email and optional global delivery capacity.
+    mw::E<AuthenticationReservation> reserveAuthenticationEmail(
+        const std::string& email_key,
+        std::int64_t now,
+        bool use_global_quota,
+        std::int64_t utc_day,
+        std::uint32_t daily_limit) override
+    {
+        auto saved = connection_.execute("SAVEPOINT email_reservation;");
+        if(!saved)
         {
-            auto game_insert = game->insertMetadata(
-                connection_, card_id, *metadata);
-            if(!game_insert)
+            return std::unexpected(std::move(saved.error()));
+        }
+        auto email_statement = connection_.statementFromStr(
+            "INSERT INTO authentication_email_limits"
+            "(email_key, next_allowed_at) VALUES (?, ?) "
+            "ON CONFLICT(email_key) DO UPDATE SET "
+            "next_allowed_at = excluded.next_allowed_at "
+            "WHERE authentication_email_limits.next_allowed_at <= ? "
+            "RETURNING next_allowed_at;");
+        if(!email_statement)
+        {
+            return rollbackReservation(std::move(email_statement.error()));
+        }
+        auto email_bind = email_statement->bind(email_key, now + 60, now);
+        if(!email_bind)
+        {
+            return rollbackReservation(std::move(email_bind.error()));
+        }
+        auto email_rows = connection_.eval<std::int64_t>(
+            std::move(*email_statement));
+        if(!email_rows)
+        {
+            return rollbackReservation(std::move(email_rows.error()));
+        }
+        if(email_rows->empty())
+        {
+            auto retry = emailRetryAfter(email_key, now);
+            rollbackReservationState();
+            if(!retry)
             {
-                return std::unexpected(std::move(game_insert.error()));
+                return std::unexpected(std::move(retry.error()));
             }
+            return AuthenticationReservation{
+                AuthenticationReservationStatus::EMAIL_LIMITED,
+                *retry};
         }
 
-        for(std::int64_t series_id : series_ids)
+        if(use_global_quota)
         {
-            auto membership = connection_.statementFromStr(
-                "INSERT INTO card_series (card_id, series_id) "
-                "VALUES (?, ?);");
-            if(!membership)
+            auto quota = connection_.statementFromStr(
+                "INSERT INTO authentication_quota"
+                "(utc_day, attempted_sends) VALUES (?, 1) "
+                "ON CONFLICT(utc_day) DO UPDATE SET "
+                "attempted_sends = attempted_sends + 1 "
+                "WHERE attempted_sends < ? RETURNING attempted_sends;");
+            if(!quota)
             {
-                return std::unexpected(std::move(membership.error()));
+                return rollbackReservation(std::move(quota.error()));
             }
-            auto membership_bind = membership->bind<std::int64_t,
-                                                    std::int64_t>(
-                card_id, series_id);
-            if(!membership_bind)
+            auto quota_bind = quota->bind(
+                utc_day, static_cast<std::int64_t>(daily_limit));
+            if(!quota_bind)
             {
-                return std::unexpected(
-                    std::move(membership_bind.error()));
+                return rollbackReservation(std::move(quota_bind.error()));
             }
-            auto membership_execute = connection_.execute(
-                std::move(*membership));
-            if(!membership_execute)
+            auto quota_rows = connection_.eval<std::int64_t>(
+                std::move(*quota));
+            if(!quota_rows)
             {
-                return std::unexpected(
-                    std::move(membership_execute.error()));
+                return rollbackReservation(std::move(quota_rows.error()));
+            }
+            if(quota_rows->empty())
+            {
+                rollbackReservationState();
+                return AuthenticationReservation{
+                    AuthenticationReservationStatus::GLOBAL_LIMITED,
+                    std::max<std::int64_t>(
+                        1, (utc_day + 1) * 86400 - now)};
             }
         }
-        return card_id;
+
+        auto released = connection_.execute(
+            "RELEASE email_reservation;");
+        if(!released)
+        {
+            return std::unexpected(std::move(released.error()));
+        }
+        return AuthenticationReservation{
+            AuthenticationReservationStatus::RESERVED, 0};
     }
 
-    /// Replace card rows after validation by the service layer.
-    mw::E<void> updateCard(
-        const Card& card,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
-        const std::vector<std::int64_t>& series_ids)
-        override
+    /// Insert a pending authentication challenge and return its ID.
+    mw::E<std::int64_t> insertAuthenticationChallenge(
+        const std::string& email,
+        const std::string& email_key,
+        const TokenHash& token_hash,
+        std::int64_t created_at,
+        std::int64_t expires_at) override
     {
-        if(card.id <= 0)
+        auto bytes = tokenBytes(token_hash);
+        if(!bytes)
         {
-            return std::unexpected(mw::runtimeError(
-                "An updated card requires an internal ID"));
+            return std::unexpected(std::move(bytes.error()));
         }
-        if((game == nullptr) != (metadata == nullptr))
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO authentication_challenges"
+            "(email, email_key, token_hash, created_at, expires_at) "
+            "VALUES (?, ?, CAST(? AS BLOB), ?, ?);");
+        if(!statement)
         {
-            return std::unexpected(mw::runtimeError(
-                "Game definition and metadata must be provided together"));
+            return std::unexpected(std::move(statement.error()));
         }
-        if(card.identity.game_short_name)
+        auto bind = statement->bind(
+            email, email_key, *bytes, created_at, expires_at);
+        if(!bind)
         {
-            if(game == nullptr ||
-               game->shortName() != *card.identity.game_short_name)
-            {
-                return std::unexpected(mw::runtimeError(
-                    "A game card requires its compiled game metadata"));
-            }
+            return std::unexpected(std::move(bind.error()));
         }
-        else if(game != nullptr || !series_ids.empty())
+        auto inserted = connection_.execute(std::move(*statement));
+        if(!inserted)
         {
-            return std::unexpected(mw::runtimeError(
-                "A loose card cannot have game metadata or series"));
+            return std::unexpected(std::move(inserted.error()));
         }
+        return connection_.lastInsertRowID();
+    }
 
+    /// Activate a pending challenge after successful delivery.
+    mw::E<bool> markAuthenticationChallengeDelivered(
+        std::int64_t challenge_id,
+        std::int64_t delivered_at) override
+    {
         auto statement = connection_.statementFromStr(
-            "UPDATE cards SET name = ?, short_description = ?, "
-            "long_description = ?, rarity = ?, front_extension = ?, "
-            "foil_extension = ?, thumbnail_extension = ?, revision = ? "
-            "WHERE id = ? AND "
-            "((? IS NULL AND game_short_name IS NULL) OR "
-            "game_short_name = ?) AND card_number = ?;");
+            "UPDATE authentication_challenges SET delivered_at = ? "
+            "WHERE id = ? AND delivered_at IS NULL AND consumed_at IS NULL "
+            "RETURNING id;");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
-        auto bind = statement->bind(
-            card.name,
-            card.short_description,
-            card.long_description,
-            card.rarity,
-            card.front_extension,
-            card.foil_extension,
-            card.thumbnail_extension,
-            card.revision,
-            card.id,
-            card.identity.game_short_name,
-            card.identity.game_short_name,
-            static_cast<std::int64_t>(card.identity.card_number));
+        auto bind = statement->bind(delivered_at, challenge_id);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
         }
-        auto update = connection_.execute(std::move(*statement));
-        if(!update)
+        auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+        if(!rows)
         {
-            return std::unexpected(std::move(update.error()));
+            return std::unexpected(std::move(rows.error()));
         }
-        auto changes = connection_.evalToValue<std::int64_t>(
-            "SELECT changes();");
-        if(!changes)
+        return !rows->empty();
+    }
+
+    /// Delete one challenge after failed delivery.
+    mw::E<void> deleteAuthenticationChallenge(
+        std::int64_t challenge_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM authentication_challenges "
+            "WHERE id = ? AND delivered_at IS NULL;");
+        if(!statement)
         {
-            return std::unexpected(std::move(changes.error()));
+            return std::unexpected(std::move(statement.error()));
         }
-        if(*changes != 1)
+        auto bind = statement->bind<std::int64_t>(challenge_id);
+        if(!bind)
         {
-            return std::unexpected(mw::runtimeError(
-                "The card disappeared while it was being updated"));
+            return std::unexpected(std::move(bind.error()));
         }
-        if(game != nullptr)
+        return connection_.execute(std::move(*statement));
+    }
+
+    /// Atomically consume one valid delivered challenge.
+    mw::E<std::optional<AuthenticationChallenge>>
+    consumeAuthenticationChallenge(
+        const TokenHash& token_hash, std::int64_t now) override
+    {
+        auto bytes = tokenBytes(token_hash);
+        if(!bytes)
         {
-            auto game_update = game->updateMetadata(
-                connection_, card.id, *metadata);
-            if(!game_update)
-            {
-                return std::unexpected(std::move(game_update.error()));
-            }
+            return std::unexpected(std::move(bytes.error()));
         }
-        auto delete_memberships = connection_.statementFromStr(
-            "DELETE FROM card_series WHERE card_id = ?;");
-        if(!delete_memberships)
+        auto statement = connection_.statementFromStr(
+            "UPDATE authentication_challenges SET consumed_at = ? "
+            "WHERE token_hash = CAST(? AS BLOB) "
+            "AND delivered_at IS NOT NULL AND consumed_at IS NULL "
+            "AND expires_at > ? "
+            "RETURNING id, email, email_key, expires_at;");
+        if(!statement)
         {
-            return std::unexpected(std::move(delete_memberships.error()));
+            return std::unexpected(std::move(statement.error()));
         }
-        auto delete_bind = delete_memberships->bind<std::int64_t>(card.id);
-        if(!delete_bind)
+        auto bind = statement->bind(now, *bytes, now);
+        if(!bind)
         {
-            return std::unexpected(std::move(delete_bind.error()));
+            return std::unexpected(std::move(bind.error()));
         }
-        auto deleted = connection_.execute(std::move(*delete_memberships));
-        if(!deleted)
+        auto rows = connection_.eval<
+            std::int64_t,
+            std::string,
+            std::string,
+            std::int64_t>(std::move(*statement));
+        if(!rows)
         {
-            return std::unexpected(std::move(deleted.error()));
+            return std::unexpected(std::move(rows.error()));
         }
-        for(std::int64_t series_id : series_ids)
+        if(rows->empty())
         {
-            auto membership = connection_.statementFromStr(
-                "INSERT INTO card_series (card_id, series_id) "
-                "VALUES (?, ?);");
-            if(!membership)
-            {
-                return std::unexpected(std::move(membership.error()));
-            }
-            auto membership_bind = membership->bind<std::int64_t,
-                                                    std::int64_t>(
-                card.id, series_id);
-            if(!membership_bind)
-            {
-                return std::unexpected(
-                    std::move(membership_bind.error()));
-            }
-            auto membership_insert = connection_.execute(
-                std::move(*membership));
-            if(!membership_insert)
-            {
-                return std::unexpected(
-                    std::move(membership_insert.error()));
-            }
+            return std::optional<AuthenticationChallenge>{};
         }
-        return {};
+        auto& [id, email, email_key, expires_at] = rows->front();
+        return AuthenticationChallenge{
+            id, std::move(email), std::move(email_key), expires_at};
     }
 
-    /// Delete a card and its dependent database rows.
-    mw::E<void> deleteCard(
-        std::int64_t card_id) override
+    /// Invalidate every other outstanding challenge for an email key.
+    mw::E<void> invalidateAuthenticationChallenges(
+        const std::string& email_key,
+        std::int64_t except_challenge_id,
+        std::int64_t now) override
     {
         auto statement = connection_.statementFromStr(
-            "DELETE FROM cards WHERE id = ?;");
+            "UPDATE authentication_challenges SET consumed_at = ? "
+            "WHERE email_key = ? AND id != ? "
+            "AND delivered_at IS NOT NULL AND consumed_at IS NULL;");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
-        auto bind = statement->bind<std::int64_t>(card_id);
+        auto bind = statement->bind(
+            now, email_key, except_challenge_id);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
@@ -557,24 +833,29 @@ public:
         return connection_.execute(std::move(*statement));
     }
 
-    /// Insert a series and return its internal ID.
-    mw::E<std::int64_t> insertSeries(
-        const Series& series) override
+    /// Insert a four-week session and return its internal ID.
+    mw::E<std::int64_t> insertSession(
+        std::int64_t user_id,
+        const TokenHash& token_hash,
+        const std::string& csrf_token,
+        std::int64_t created_at,
+        std::int64_t expires_at) override
     {
-        if(series.id != 0)
+        auto bytes = tokenBytes(token_hash);
+        if(!bytes)
         {
-            return std::unexpected(mw::runtimeError(
-                "A new series cannot already have an internal ID"));
+            return std::unexpected(std::move(bytes.error()));
         }
         auto statement = connection_.statementFromStr(
-            "INSERT INTO series (game_short_name, name, description) "
-            "VALUES (?, ?, ?);");
+            "INSERT INTO sessions(user_id, token_hash, csrf_token, "
+            "created_at, expires_at) "
+            "VALUES (?, CAST(? AS BLOB), ?, ?, ?);");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
         auto bind = statement->bind(
-            series.game_short_name, series.name, series.description);
+            user_id, *bytes, csrf_token, created_at, expires_at);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
@@ -587,22 +868,21 @@ public:
         return connection_.lastInsertRowID();
     }
 
-    /// Replace a series name and description without changing its game.
-    mw::E<void> updateSeries(
-        const Series& series) override
+    /// Delete a session by its token digest.
+    mw::E<void> deleteSession(const TokenHash& token_hash) override
     {
+        auto bytes = tokenBytes(token_hash);
+        if(!bytes)
+        {
+            return std::unexpected(std::move(bytes.error()));
+        }
         auto statement = connection_.statementFromStr(
-            "UPDATE series SET name = ?, description = ? "
-            "WHERE id = ? AND game_short_name = ?;");
+            "DELETE FROM sessions WHERE token_hash = CAST(? AS BLOB);");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
-        auto bind = statement->bind(
-            series.name,
-            series.description,
-            series.id,
-            series.game_short_name);
+        auto bind = statement->bind<std::string>(*bytes);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
@@ -610,93 +890,693 @@ public:
         return connection_.execute(std::move(*statement));
     }
 
-    /// Delete a series and its membership rows.
-    mw::E<void> deleteSeries(
-        std::int64_t series_id) override
+    /// Persist a lazily refreshed pull state.
+    mw::E<void> updatePullState(
+        std::int64_t user_id,
+        std::uint32_t stored_pulls,
+        std::int64_t refresh_day) override
     {
         auto statement = connection_.statementFromStr(
-            "DELETE FROM series WHERE id = ?;");
+            "UPDATE users SET stored_pulls = ?, pull_refresh_day = ? "
+            "WHERE id = ?;");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
-        auto bind = statement->bind<std::int64_t>(series_id);
+        auto bind = statement->bind(
+            static_cast<std::int64_t>(stored_pulls),
+            refresh_day,
+            user_id);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
         }
-        return connection_.execute(std::move(*statement));
-    }
-
-    /// Commit the transaction and release its connection lock.
-    mw::E<void> commit() override
-    {
-        if(committed_)
+        auto updated = connection_.execute(std::move(*statement));
+        if(!updated)
         {
-            return std::unexpected(mw::runtimeError(
-                "Transaction has already been committed"));
+            return std::unexpected(std::move(updated.error()));
         }
-        auto result = connection_.execute("COMMIT;");
-        if(!result)
+        if(connection_.changedRowsCount() != 1)
         {
-            return std::unexpected(std::move(result.error()));
+            return std::unexpected(mw::runtimeError(
+                "User disappeared while updating pull state"));
         }
-        committed_ = true;
-        lock_.unlock();
         return {};
     }
 
-private:
-    mw::SQLite& connection_;
-    std::unique_lock<std::mutex> lock_;
-    bool committed_ = false;
-};
-
-} // namespace
-
-DataSourceSQLite::DataSourceSQLite(
-    std::unique_ptr<mw::SQLite> connection)
-        : connection_(std::move(connection))
-{}
-
-mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
-    const std::filesystem::path& database_path)
-{
-    auto connection = mw::SQLite::connectFile(database_path.string());
-    if(!connection)
-    {
-        return std::unexpected(std::move(connection.error()));
-    }
-    auto synchronous_result =
-        (*connection)->execute("PRAGMA synchronous = NORMAL;");
-    if(!synchronous_result)
-    {
-        return std::unexpected(std::move(synchronous_result.error()));
-    }
-    const std::array<std::string_view, 3> pragmas = {
-        "PRAGMA foreign_keys = ON;",
-        "PRAGMA journal_mode = WAL;",
-        "PRAGMA busy_timeout = 5000;",
-    };
-    for(std::string_view pragma : pragmas)
+    /// Insert or increment a holding and return its new quantity.
+    mw::E<std::int64_t> incrementHolding(
+        std::int64_t user_id, std::int64_t card_id) override
     {
-        auto result = (*connection)->execute(std::string(pragma));
-        if(!result)
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO card_holdings(user_id, card_id, quantity) "
+            "VALUES (?, ?, 1) ON CONFLICT(user_id, card_id) DO UPDATE "
+            "SET quantity = quantity + 1 "
+            "WHERE quantity < 9223372036854775807 RETURNING quantity;");
+        if(!statement)
         {
-            return std::unexpected(std::move(result.error()));
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(user_id, card_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
         }
+        auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        if(rows->size() != 1)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Card quantity cannot be incremented"));
+        }
+        return std::get<0>(rows->front());
     }
-    return std::unique_ptr<DataSourceSQLite>(
-        new DataSourceSQLite(std::move(*connection)));
-}
-
-mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
-{
-    std::lock_guard lock(mutex_);
-    return connection_->evalToValue<std::int64_t>("PRAGMA user_version;");
-}
 
-mw::E<void> DataSourceSQLite::migrateSchema0To1(
+    /// Re-read a card while the transaction lock is held.
+    mw::E<std::optional<Card>> getCardForUpdate(
+        std::int64_t card_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "SELECT id, creator_user_id, game_short_name, card_number, name, "
+            "short_description, long_description, rarity, "
+            "front_extension, foil_extension, thumbnail_extension, "
+            "revision FROM cards WHERE id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::int64_t>(card_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto rows = connection_.eval<
+            std::int64_t,
+            std::int64_t,
+            std::optional<std::string>,
+            std::int64_t,
+            std::string,
+            std::optional<std::string>,
+            std::optional<std::string>,
+            std::int64_t,
+            std::string,
+            std::optional<std::string>,
+            std::string,
+            std::int64_t>(std::move(*statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        if(rows->empty())
+        {
+            return std::optional<Card>{};
+        }
+
+        auto& [
+            id,
+            creator_user_id,
+            game_short_name,
+            card_number,
+            name,
+            short_description,
+            long_description,
+            rarity,
+            front_extension,
+            foil_extension,
+            thumbnail_extension,
+            revision] = rows->front();
+        Card card = {
+            id,
+            {std::move(game_short_name),
+             static_cast<std::uint64_t>(card_number)},
+            std::move(name),
+            std::move(short_description),
+            std::move(long_description),
+            rarity,
+            std::move(front_extension),
+            std::move(foil_extension),
+            std::move(thumbnail_extension),
+            revision,
+            creator_user_id,
+        };
+        return std::optional<Card>(std::move(card));
+    }
+
+    /// Insert common and series-membership rows for a loose card.
+    mw::E<std::int64_t> insertCard(
+        const Card& card,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids) override
+    {
+        if(card.id != 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A new card cannot already have an internal ID"));
+        }
+        if((game == nullptr) != (metadata == nullptr))
+        {
+            return std::unexpected(mw::runtimeError(
+                "Game definition and metadata must be provided together"));
+        }
+        if(card.identity.game_short_name)
+        {
+            if(game == nullptr)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "A game card requires compiled game metadata"));
+            }
+            if(game->shortName() != *card.identity.game_short_name)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "Card and compiled game identities differ"));
+            }
+        }
+        else if(game != nullptr || !series_ids.empty())
+        {
+            return std::unexpected(mw::runtimeError(
+                "A loose card cannot have game metadata or series"));
+        }
+        if(!card.identity.game_short_name &&
+           card.identity.card_number >
+           std::numeric_limits<std::uint32_t>::max())
+        {
+            return std::unexpected(mw::runtimeError(
+                "Loose card number exceeds the 32-bit namespace"));
+        }
+
+        auto insert = connection_.statementFromStr(
+            "INSERT INTO cards ("
+            "creator_user_id, game_short_name, card_number, name, "
+            "short_description, "
+            "long_description, rarity, front_extension, foil_extension, "
+            "thumbnail_extension, revision) "
+            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
+        if(!insert)
+        {
+            return std::unexpected(std::move(insert.error()));
+        }
+        auto bind = insert->bind(
+            card.creator_user_id,
+            card.identity.game_short_name,
+            static_cast<std::int64_t>(card.identity.card_number),
+            card.name,
+            card.short_description,
+            card.long_description,
+            card.rarity,
+            card.front_extension,
+            card.foil_extension,
+            card.thumbnail_extension,
+            card.revision);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto execute = connection_.execute(std::move(*insert));
+        if(!execute)
+        {
+            return std::unexpected(std::move(execute.error()));
+        }
+        const std::int64_t card_id = connection_.lastInsertRowID();
+
+        if(game != nullptr)
+        {
+            auto game_insert = game->insertMetadata(
+                connection_, card_id, *metadata);
+            if(!game_insert)
+            {
+                return std::unexpected(std::move(game_insert.error()));
+            }
+        }
+
+        for(std::int64_t series_id : series_ids)
+        {
+            auto membership = connection_.statementFromStr(
+                "INSERT INTO card_series (card_id, series_id) "
+                "VALUES (?, ?);");
+            if(!membership)
+            {
+                return std::unexpected(std::move(membership.error()));
+            }
+            auto membership_bind = membership->bind<std::int64_t,
+                                                    std::int64_t>(
+                card_id, series_id);
+            if(!membership_bind)
+            {
+                return std::unexpected(
+                    std::move(membership_bind.error()));
+            }
+            auto membership_execute = connection_.execute(
+                std::move(*membership));
+            if(!membership_execute)
+            {
+                return std::unexpected(
+                    std::move(membership_execute.error()));
+            }
+        }
+        return card_id;
+    }
+
+    /// Replace card rows after validation by the service layer.
+    mw::E<void> updateCard(
+        const Card& card,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids)
+        override
+    {
+        if(card.id <= 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "An updated card requires an internal ID"));
+        }
+        if((game == nullptr) != (metadata == nullptr))
+        {
+            return std::unexpected(mw::runtimeError(
+                "Game definition and metadata must be provided together"));
+        }
+        if(card.identity.game_short_name)
+        {
+            if(game == nullptr ||
+               game->shortName() != *card.identity.game_short_name)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "A game card requires its compiled game metadata"));
+            }
+        }
+        else if(game != nullptr || !series_ids.empty())
+        {
+            return std::unexpected(mw::runtimeError(
+                "A loose card cannot have game metadata or series"));
+        }
+
+        auto statement = connection_.statementFromStr(
+            "UPDATE cards SET name = ?, short_description = ?, "
+            "long_description = ?, rarity = ?, front_extension = ?, "
+            "foil_extension = ?, thumbnail_extension = ?, revision = ? "
+            "WHERE id = ? AND "
+            "((? IS NULL AND game_short_name IS NULL) OR "
+            "game_short_name = ?) AND card_number = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            card.name,
+            card.short_description,
+            card.long_description,
+            card.rarity,
+            card.front_extension,
+            card.foil_extension,
+            card.thumbnail_extension,
+            card.revision,
+            card.id,
+            card.identity.game_short_name,
+            card.identity.game_short_name,
+            static_cast<std::int64_t>(card.identity.card_number));
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto update = connection_.execute(std::move(*statement));
+        if(!update)
+        {
+            return std::unexpected(std::move(update.error()));
+        }
+        auto changes = connection_.evalToValue<std::int64_t>(
+            "SELECT changes();");
+        if(!changes)
+        {
+            return std::unexpected(std::move(changes.error()));
+        }
+        if(*changes != 1)
+        {
+            return std::unexpected(mw::runtimeError(
+                "The card disappeared while it was being updated"));
+        }
+        if(game != nullptr)
+        {
+            auto game_update = game->updateMetadata(
+                connection_, card.id, *metadata);
+            if(!game_update)
+            {
+                return std::unexpected(std::move(game_update.error()));
+            }
+        }
+        auto delete_memberships = connection_.statementFromStr(
+            "DELETE FROM card_series WHERE card_id = ?;");
+        if(!delete_memberships)
+        {
+            return std::unexpected(std::move(delete_memberships.error()));
+        }
+        auto delete_bind = delete_memberships->bind<std::int64_t>(card.id);
+        if(!delete_bind)
+        {
+            return std::unexpected(std::move(delete_bind.error()));
+        }
+        auto deleted = connection_.execute(std::move(*delete_memberships));
+        if(!deleted)
+        {
+            return std::unexpected(std::move(deleted.error()));
+        }
+        for(std::int64_t series_id : series_ids)
+        {
+            auto membership = connection_.statementFromStr(
+                "INSERT INTO card_series (card_id, series_id) "
+                "VALUES (?, ?);");
+            if(!membership)
+            {
+                return std::unexpected(std::move(membership.error()));
+            }
+            auto membership_bind = membership->bind<std::int64_t,
+                                                    std::int64_t>(
+                card.id, series_id);
+            if(!membership_bind)
+            {
+                return std::unexpected(
+                    std::move(membership_bind.error()));
+            }
+            auto membership_insert = connection_.execute(
+                std::move(*membership));
+            if(!membership_insert)
+            {
+                return std::unexpected(
+                    std::move(membership_insert.error()));
+            }
+        }
+        return {};
+    }
+
+    /// Delete a card and its dependent database rows.
+    mw::E<void> deleteCard(
+        std::int64_t card_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM cards WHERE id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::int64_t>(card_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    /// Insert a series and return its internal ID.
+    mw::E<std::int64_t> insertSeries(
+        const Series& series) override
+    {
+        if(series.id != 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A new series cannot already have an internal ID"));
+        }
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO series (game_short_name, name, description) "
+            "VALUES (?, ?, ?);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            series.game_short_name, series.name, series.description);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto inserted = connection_.execute(std::move(*statement));
+        if(!inserted)
+        {
+            return std::unexpected(std::move(inserted.error()));
+        }
+        return connection_.lastInsertRowID();
+    }
+
+    /// Replace a series name and description without changing its game.
+    mw::E<void> updateSeries(
+        const Series& series) override
+    {
+        auto statement = connection_.statementFromStr(
+            "UPDATE series SET name = ?, description = ? "
+            "WHERE id = ? AND game_short_name = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            series.name,
+            series.description,
+            series.id,
+            series.game_short_name);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    /// Delete a series and its membership rows.
+    mw::E<void> deleteSeries(
+        std::int64_t series_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM series WHERE id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::int64_t>(series_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    /// Commit the transaction and release its connection lock.
+    mw::E<void> commit() override
+    {
+        if(committed_)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Transaction has already been committed"));
+        }
+        auto result = connection_.execute("COMMIT;");
+        if(!result)
+        {
+            return std::unexpected(std::move(result.error()));
+        }
+        committed_ = true;
+        lock_.unlock();
+        return {};
+    }
+
+private:
+    mw::E<std::optional<User>> readUser(mw::SQLiteStatement statement)
+    {
+        auto rows = connection_.eval<
+            std::int64_t,
+            std::string,
+            std::string,
+            std::optional<std::string>,
+            std::int64_t,
+            std::int64_t,
+            std::int64_t,
+            std::int64_t>(std::move(statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        if(rows->empty())
+        {
+            return std::optional<User>{};
+        }
+        auto& [id, email, email_key, username, role_value, stored_pulls,
+               refresh_day, created_at] = rows->front();
+        auto role = userRole(role_value);
+        if(!role || stored_pulls < 0 ||
+           stored_pulls > std::numeric_limits<std::uint32_t>::max())
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid user record"));
+        }
+        return User{
+            id,
+            std::move(email),
+            std::move(email_key),
+            std::move(username),
+            *role,
+            static_cast<std::uint32_t>(stored_pulls),
+            refresh_day,
+            created_at};
+    }
+
+    mw::E<std::vector<Card>> readCards(const std::string& suffix)
+    {
+        auto rows = connection_.eval<
+            std::int64_t,
+            std::int64_t,
+            std::optional<std::string>,
+            std::int64_t,
+            std::string,
+            std::optional<std::string>,
+            std::optional<std::string>,
+            std::int64_t,
+            std::string,
+            std::optional<std::string>,
+            std::string,
+            std::int64_t>(
+                "SELECT id, creator_user_id, game_short_name, card_number, "
+                "name, short_description, long_description, rarity, "
+                "front_extension, foil_extension, thumbnail_extension, "
+                "revision FROM cards " + suffix + ";");
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        std::vector<Card> result;
+        result.reserve(rows->size());
+        for(auto& [id, creator_user_id, game_short_name, card_number, name,
+                   short_description, long_description, rarity,
+                   front_extension, foil_extension, thumbnail_extension,
+                   revision] : *rows)
+        {
+            result.push_back({
+                id,
+                {std::move(game_short_name),
+                 static_cast<std::uint64_t>(card_number)},
+                std::move(name),
+                std::move(short_description),
+                std::move(long_description),
+                rarity,
+                std::move(front_extension),
+                std::move(foil_extension),
+                std::move(thumbnail_extension),
+                revision,
+                creator_user_id});
+        }
+        return result;
+    }
+
+    mw::E<std::int64_t> emailRetryAfter(
+        const std::string& email_key, std::int64_t now)
+    {
+        auto statement = connection_.statementFromStr(
+            "SELECT next_allowed_at FROM authentication_email_limits "
+            "WHERE email_key = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::string>(email_key);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto next = connection_.evalToValue<std::int64_t>(
+            std::move(*statement));
+        if(!next)
+        {
+            return std::unexpected(std::move(next.error()));
+        }
+        return std::max<std::int64_t>(1, *next - now);
+    }
+
+    void rollbackReservationState()
+    {
+        auto rolled_back = connection_.execute(
+            "ROLLBACK TO email_reservation;");
+        auto released = connection_.execute("RELEASE email_reservation;");
+        if(!rolled_back || !released)
+        {
+            spdlog::error("Failed to roll back email quota reservation");
+        }
+    }
+
+    mw::E<AuthenticationReservation> rollbackReservation(mw::Error error)
+    {
+        rollbackReservationState();
+        return std::unexpected(std::move(error));
+    }
+
+    mw::SQLite& connection_;
+    std::unique_lock<std::mutex> lock_;
+    bool committed_ = false;
+};
+
+} // namespace
+
+DataSourceSQLite::DataSourceSQLite(
+    std::unique_ptr<mw::SQLite> connection)
+        : connection_(std::move(connection))
+{}
+
+mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
+    const std::filesystem::path& database_path)
+{
+    auto connection = mw::SQLite::connectFile(database_path.string());
+    if(!connection)
+    {
+        return std::unexpected(std::move(connection.error()));
+    }
+    auto synchronous_result =
+        (*connection)->execute("PRAGMA synchronous = NORMAL;");
+    if(!synchronous_result)
+    {
+        return std::unexpected(std::move(synchronous_result.error()));
+    }
+    const std::array<std::string_view, 3> pragmas = {
+        "PRAGMA foreign_keys = ON;",
+        "PRAGMA journal_mode = WAL;",
+        "PRAGMA busy_timeout = 5000;",
+    };
+    for(std::string_view pragma : pragmas)
+    {
+        auto result = (*connection)->execute(std::string(pragma));
+        if(!result)
+        {
+            return std::unexpected(std::move(result.error()));
+        }
+    }
+    return std::unique_ptr<DataSourceSQLite>(
+        new DataSourceSQLite(std::move(*connection)));
+}
+
+mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
+{
+    std::lock_guard lock(mutex_);
+    auto version = connection_->evalToValue<std::int64_t>(
+        "PRAGMA user_version;");
+    if(!version)
+    {
+        return std::unexpected(std::move(version.error()));
+    }
+    if(*version == 1)
+    {
+        auto users = connection_->evalToValue<int>(
+            "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
+            "WHERE type = 'table' AND name = 'users');");
+        if(!users)
+        {
+            return std::unexpected(std::move(users.error()));
+        }
+        if(*users == 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database uses the obsolete prototype schema version 1; "
+                "delete and recreate this unreleased development database"));
+        }
+    }
+    return *version;
+}
+
+mw::E<void> DataSourceSQLite::migrateSchema0To1(
     const GameRegistry& games)
 {
     std::lock_guard lock(mutex_);
@@ -767,6 +1647,7 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
 {
     std::lock_guard lock(mutex_);
     auto rows = connection_->eval<
+        std::int64_t,
         std::int64_t,
         std::optional<std::string>,
         std::int64_t,
@@ -778,7 +1659,7 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
         std::optional<std::string>,
         std::string,
         std::int64_t>(
-            "SELECT id, game_short_name, card_number, name, "
+            "SELECT id, creator_user_id, game_short_name, card_number, name, "
             "short_description, long_description, rarity, "
             "front_extension, foil_extension, thumbnail_extension, "
             "revision FROM cards ORDER BY id;");
@@ -793,6 +1674,7 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
     {
         auto& [
             id,
+            creator_user_id,
             game_short_name,
             card_number,
             name,
@@ -828,9 +1710,60 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
             std::move(foil_extension),
             std::move(thumbnail_extension),
             revision,
+            creator_user_id,
         });
     }
-    return cards;
+    return cards;
+}
+
+mw::E<std::vector<Card>> DataSourceSQLite::getCardsByCreator(
+    std::int64_t creator_user_id) const
+{
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT id, creator_user_id, game_short_name, card_number, name, "
+        "short_description, long_description, rarity, front_extension, "
+        "foil_extension, thumbnail_extension, revision FROM cards "
+        "WHERE creator_user_id = ? ORDER BY id;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::int64_t>(creator_user_id);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t, std::int64_t, std::optional<std::string>,
+        std::int64_t, std::string, std::optional<std::string>,
+        std::optional<std::string>, std::int64_t, std::string,
+        std::optional<std::string>, std::string, std::int64_t>(
+            std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    return cardsFromRows(std::move(*rows));
+}
+
+mw::E<std::vector<Card>> DataSourceSQLite::getPoolCards() const
+{
+    std::lock_guard lock(mutex_);
+    auto rows = connection_->eval<
+        std::int64_t, std::int64_t, std::optional<std::string>,
+        std::int64_t, std::string, std::optional<std::string>,
+        std::optional<std::string>, std::int64_t, std::string,
+        std::optional<std::string>, std::string, std::int64_t>(
+            "SELECT id, creator_user_id, game_short_name, card_number, "
+            "name, short_description, long_description, rarity, "
+            "front_extension, foil_extension, thumbnail_extension, "
+            "revision FROM cards WHERE rarity > 0 ORDER BY id;");
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    return cardsFromRows(std::move(*rows));
 }
 
 mw::E<std::optional<Card>> DataSourceSQLite::getCard(
@@ -838,7 +1771,7 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
 {
     std::lock_guard lock(mutex_);
     auto statement = connection_->statementFromStr(
-        "SELECT id, game_short_name, card_number, name, "
+        "SELECT id, creator_user_id, game_short_name, card_number, name, "
         "short_description, long_description, rarity, "
         "front_extension, foil_extension, thumbnail_extension, "
         "revision FROM cards WHERE "
@@ -857,6 +1790,7 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
         return std::unexpected(std::move(bind.error()));
     }
     auto rows = connection_->eval<
+        std::int64_t,
         std::int64_t,
         std::optional<std::string>,
         std::int64_t,
@@ -879,6 +1813,7 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
 
     auto& [
         id,
+        creator_user_id,
         game_short_name,
         card_number,
         name,
@@ -901,10 +1836,490 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
         std::move(foil_extension),
         std::move(thumbnail_extension),
         revision,
+        creator_user_id,
     };
     return std::optional<Card>(std::move(card));
 }
 
+mw::E<std::optional<User>> DataSourceSQLite::getUser(
+    std::int64_t user_id) const
+{
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT id, email, email_key, username, role, stored_pulls, "
+        "pull_refresh_day, created_at FROM users WHERE id = ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::int64_t>(user_id);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t, std::string, std::string,
+        std::optional<std::string>, std::int64_t, std::int64_t,
+        std::int64_t, std::int64_t>(std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    if(rows->empty())
+    {
+        return std::optional<User>{};
+    }
+    auto user = userFromRow(std::move(rows->front()));
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    return std::optional<User>(std::move(*user));
+}
+
+mw::E<std::optional<User>> DataSourceSQLite::getUserByEmailKey(
+    const std::string& email_key) const
+{
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT id, email, email_key, username, role, stored_pulls, "
+        "pull_refresh_day, created_at FROM users WHERE email_key = ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::string>(email_key);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t, std::string, std::string,
+        std::optional<std::string>, std::int64_t, std::int64_t,
+        std::int64_t, std::int64_t>(std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    if(rows->empty())
+    {
+        return std::optional<User>{};
+    }
+    auto user = userFromRow(std::move(rows->front()));
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    return std::optional<User>(std::move(*user));
+}
+
+mw::E<std::vector<User>> DataSourceSQLite::getUsers() const
+{
+    std::lock_guard lock(mutex_);
+    auto rows = connection_->eval<
+        std::int64_t, std::string, std::string,
+        std::optional<std::string>, std::int64_t, std::int64_t,
+        std::int64_t, std::int64_t>(
+            "SELECT id, email, email_key, username, role, stored_pulls, "
+            "pull_refresh_day, created_at FROM users ORDER BY id;");
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    std::vector<User> users;
+    users.reserve(rows->size());
+    for(UserRow& row : *rows)
+    {
+        auto user = userFromRow(std::move(row));
+        if(!user)
+        {
+            return std::unexpected(std::move(user.error()));
+        }
+        users.push_back(std::move(*user));
+    }
+    return users;
+}
+
+mw::E<std::optional<SessionContext>> DataSourceSQLite::getSession(
+    const TokenHash& token_hash, std::int64_t now) const
+{
+    auto bytes = tokenBytes(token_hash);
+    if(!bytes)
+    {
+        return std::unexpected(std::move(bytes.error()));
+    }
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT s.id, u.id, u.email, u.email_key, u.username, u.role, "
+        "u.stored_pulls, u.pull_refresh_day, u.created_at, s.csrf_token, "
+        "s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id "
+        "WHERE s.token_hash = CAST(? AS BLOB) AND s.expires_at > ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind(*bytes, now);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t, std::int64_t, std::string, std::string,
+        std::optional<std::string>, std::int64_t, std::int64_t,
+        std::int64_t, std::int64_t, std::string, std::int64_t>(
+            std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    if(rows->empty())
+    {
+        return std::optional<SessionContext>{};
+    }
+    auto& [session_id, user_id, email, email_key, username, role_value,
+           stored_pulls, refresh_day, created_at, csrf_token,
+           expires_at] = rows->front();
+    auto user = userFromRow(UserRow{
+        user_id,
+        std::move(email),
+        std::move(email_key),
+        std::move(username),
+        role_value,
+        stored_pulls,
+        refresh_day,
+        created_at});
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    return SessionContext{
+        session_id, std::move(*user), std::move(csrf_token), expires_at};
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+DataSourceSQLite::getAuthenticationChallenge(
+    const TokenHash& token_hash, std::int64_t now) const
+{
+    auto bytes = tokenBytes(token_hash);
+    if(!bytes)
+    {
+        return std::unexpected(std::move(bytes.error()));
+    }
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT id, email, email_key, expires_at "
+        "FROM authentication_challenges "
+        "WHERE token_hash = CAST(? AS BLOB) "
+        "AND delivered_at IS NOT NULL AND consumed_at IS NULL "
+        "AND expires_at > ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind(*bytes, now);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t, std::string, std::string, std::int64_t>(
+            std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    if(rows->empty())
+    {
+        return std::optional<AuthenticationChallenge>{};
+    }
+    auto& [id, email, email_key, expires_at] = rows->front();
+    return AuthenticationChallenge{
+        id, std::move(email), std::move(email_key), expires_at};
+}
+
+mw::E<std::vector<CollectionEntry>> DataSourceSQLite::getCollection(
+    std::int64_t user_id) const
+{
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT c.id, c.creator_user_id, c.game_short_name, c.card_number, "
+        "c.name, c.short_description, c.long_description, c.rarity, "
+        "c.front_extension, c.foil_extension, c.thumbnail_extension, "
+        "c.revision, h.quantity FROM card_holdings h "
+        "JOIN cards c ON c.id = h.card_id WHERE h.user_id = ? "
+        "ORDER BY c.id;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::int64_t>(user_id);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t, std::int64_t, std::optional<std::string>,
+        std::int64_t, std::string, std::optional<std::string>,
+        std::optional<std::string>, std::int64_t, std::string,
+        std::optional<std::string>, std::string, std::int64_t,
+        std::int64_t>(std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    std::vector<CollectionEntry> result;
+    result.reserve(rows->size());
+    for(auto& [id, creator_user_id, game_short_name, card_number, name,
+               short_description, long_description, rarity, front_extension,
+               foil_extension, thumbnail_extension, revision,
+               quantity] : *rows)
+    {
+        if(quantity <= 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid holding"));
+        }
+        auto card = cardFromRow(CardRow{
+            id,
+            creator_user_id,
+            std::move(game_short_name),
+            card_number,
+            std::move(name),
+            std::move(short_description),
+            std::move(long_description),
+            rarity,
+            std::move(front_extension),
+            std::move(foil_extension),
+            std::move(thumbnail_extension),
+            revision});
+        if(!card)
+        {
+            return std::unexpected(std::move(card.error()));
+        }
+        result.push_back({std::move(*card), quantity});
+    }
+    return result;
+}
+
+mw::E<bool> DataSourceSQLite::userOwnsCard(
+    std::int64_t user_id, std::int64_t card_id) const
+{
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT EXISTS(SELECT 1 FROM card_holdings "
+        "WHERE user_id = ? AND card_id = ?);");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind(user_id, card_id);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto result = connection_->evalToValue<int>(std::move(*statement));
+    if(!result)
+    {
+        return std::unexpected(std::move(result.error()));
+    }
+    return *result != 0;
+}
+
+mw::E<User> DataSourceSQLite::reconcileAdministrator(
+    const std::string& email,
+    const std::string& email_key,
+    std::int64_t created_at,
+    std::int64_t pull_refresh_day)
+{
+    std::lock_guard lock(mutex_);
+    const auto rollback = [this](mw::Error error) -> mw::E<User>
+    {
+        auto result = connection_->execute("ROLLBACK;");
+        if(!result)
+        {
+            spdlog::error(
+                "Failed to roll back administrator reconciliation: {}",
+                result.error().msg());
+        }
+        return std::unexpected(std::move(error));
+    };
+    auto begin = connection_->execute("BEGIN IMMEDIATE;");
+    if(!begin)
+    {
+        return std::unexpected(std::move(begin.error()));
+    }
+    auto metadata = connection_->eval<std::string>(
+        "SELECT value FROM application_metadata "
+        "WHERE key = 'administrator_email_key';");
+    if(!metadata)
+    {
+        return rollback(std::move(metadata.error()));
+    }
+    if(!metadata->empty() &&
+       std::get<0>(metadata->front()) != email_key)
+    {
+        return rollback(mw::runtimeError(
+                "Configured administrator email differs from the "
+                "initialized database"));
+    }
+    if(metadata->empty())
+    {
+        auto statement = connection_->statementFromStr(
+            "INSERT INTO application_metadata(key, value) "
+            "VALUES ('administrator_email_key', ?);");
+        if(!statement)
+        {
+            return rollback(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::string>(email_key);
+        if(!bind)
+        {
+            return rollback(std::move(bind.error()));
+        }
+        auto inserted = connection_->execute(std::move(*statement));
+        if(!inserted)
+        {
+            return rollback(std::move(inserted.error()));
+        }
+    }
+
+    auto administrator_rows = connection_->eval<
+        std::int64_t, std::string, std::string,
+        std::optional<std::string>, std::int64_t, std::int64_t,
+        std::int64_t, std::int64_t>(
+            "SELECT id, email, email_key, username, role, stored_pulls, "
+            "pull_refresh_day, created_at FROM users WHERE role = 2;");
+    if(!administrator_rows)
+    {
+        return rollback(std::move(administrator_rows.error()));
+    }
+    if(!administrator_rows->empty() &&
+       std::get<2>(administrator_rows->front()) != email_key)
+    {
+        return rollback(mw::runtimeError(
+                "Database administrator account has an unexpected email"));
+    }
+    if(administrator_rows->empty())
+    {
+        auto insert = connection_->statementFromStr(
+            "INSERT INTO users(email, email_key, username, username_key, "
+            "role, stored_pulls, pull_refresh_day, created_at) "
+            "VALUES (?, ?, NULL, NULL, 2, 1, ?, ?);");
+        if(!insert)
+        {
+            return rollback(std::move(insert.error()));
+        }
+        auto bind = insert->bind(
+            email, email_key, pull_refresh_day, created_at);
+        if(!bind)
+        {
+            return rollback(std::move(bind.error()));
+        }
+        auto inserted = connection_->execute(std::move(*insert));
+        if(!inserted)
+        {
+            return rollback(std::move(inserted.error()));
+        }
+        administrator_rows = connection_->eval<
+            std::int64_t, std::string, std::string,
+            std::optional<std::string>, std::int64_t, std::int64_t,
+            std::int64_t, std::int64_t>(
+                "SELECT id, email, email_key, username, role, stored_pulls, "
+                "pull_refresh_day, created_at FROM users WHERE role = 2;");
+        if(!administrator_rows)
+        {
+            return rollback(std::move(administrator_rows.error()));
+        }
+    }
+    if(administrator_rows->size() != 1)
+    {
+        return rollback(mw::runtimeError(
+            "Database must contain one administrator"));
+    }
+    auto administrator = userFromRow(
+        std::move(administrator_rows->front()));
+    if(!administrator)
+    {
+        return rollback(std::move(administrator.error()));
+    }
+    auto commit = connection_->execute("COMMIT;");
+    if(!commit)
+    {
+        return rollback(std::move(commit.error()));
+    }
+    return std::move(*administrator);
+}
+
+mw::E<void> DataSourceSQLite::cleanupAuthentication(std::int64_t now)
+{
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "DELETE FROM sessions WHERE expires_at <= ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::int64_t>(now);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto sessions = connection_->execute(std::move(*statement));
+    if(!sessions)
+    {
+        return std::unexpected(std::move(sessions.error()));
+    }
+    const std::int64_t cutoff = now - 86400;
+    auto challenges = connection_->statementFromStr(
+        "DELETE FROM authentication_challenges "
+        "WHERE (consumed_at IS NOT NULL AND consumed_at < ?) "
+        "OR (consumed_at IS NULL AND expires_at < ?);");
+    if(!challenges)
+    {
+        return std::unexpected(std::move(challenges.error()));
+    }
+    auto challenge_bind = challenges->bind(cutoff, cutoff);
+    if(!challenge_bind)
+    {
+        return std::unexpected(std::move(challenge_bind.error()));
+    }
+    auto challenge_delete = connection_->execute(std::move(*challenges));
+    if(!challenge_delete)
+    {
+        return std::unexpected(std::move(challenge_delete.error()));
+    }
+    auto limits = connection_->statementFromStr(
+        "DELETE FROM authentication_email_limits "
+        "WHERE next_allowed_at < ?;");
+    if(!limits)
+    {
+        return std::unexpected(std::move(limits.error()));
+    }
+    auto limit_bind = limits->bind<std::int64_t>(cutoff);
+    if(!limit_bind)
+    {
+        return std::unexpected(std::move(limit_bind.error()));
+    }
+    auto limit_delete = connection_->execute(std::move(*limits));
+    if(!limit_delete)
+    {
+        return std::unexpected(std::move(limit_delete.error()));
+    }
+    const std::int64_t day = now >= 0 ? now / 86400 : 0;
+    auto quota = connection_->statementFromStr(
+        "DELETE FROM authentication_quota WHERE utc_day < ?;");
+    if(!quota)
+    {
+        return std::unexpected(std::move(quota.error()));
+    }
+    auto quota_bind = quota->bind<std::int64_t>(day - 1);
+    if(!quota_bind)
+    {
+        return std::unexpected(std::move(quota_bind.error()));
+    }
+    return connection_->execute(std::move(*quota));
+}
+
 mw::E<std::vector<DisplayField>>
 DataSourceSQLite::getGameDisplayFields(
     const GameDefinition& game,
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index aca4c42..0b51464 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -39,10 +39,55 @@ public:
     /// Return all cards for the unpaginated index.
     mw::E<std::vector<Card>> getCards() const override;
 
+    /// Return all cards authored by one user.
+    mw::E<std::vector<Card>> getCardsByCreator(
+        std::int64_t creator_user_id) const override;
+
+    /// Return every positive-rarity card in card-ID order.
+    mw::E<std::vector<Card>> getPoolCards() const override;
+
     /// Return a card by its parsed identity.
     mw::E<std::optional<Card>>
     getCard(const CardIdentity& identity) const override;
 
+    /// Return a user by internal identity.
+    mw::E<std::optional<User>> getUser(
+        std::int64_t user_id) const override;
+
+    /// Return a user by normalized immutable email identity.
+    mw::E<std::optional<User>> getUserByEmailKey(
+        const std::string& email_key) const override;
+
+    /// Return every user for administrator management.
+    mw::E<std::vector<User>> getUsers() const override;
+
+    /// Return a valid session joined with its current user.
+    mw::E<std::optional<SessionContext>> getSession(
+        const TokenHash& token_hash, std::int64_t now) const override;
+
+    /// Read one valid delivered challenge without consuming it.
+    mw::E<std::optional<AuthenticationChallenge>>
+    getAuthenticationChallenge(
+        const TokenHash& token_hash, std::int64_t now) const override;
+
+    /// Return a user's distinct collection entries.
+    mw::E<std::vector<CollectionEntry>> getCollection(
+        std::int64_t user_id) const override;
+
+    /// Return whether a user currently owns a card.
+    mw::E<bool> userOwnsCard(
+        std::int64_t user_id, std::int64_t card_id) const override;
+
+    /// Reconcile the one immutable configured administrator identity.
+    mw::E<User> reconcileAdministrator(
+        const std::string& email,
+        const std::string& email_key,
+        std::int64_t created_at,
+        std::int64_t pull_refresh_day) override;
+
+    /// Best-effort removal of obsolete authentication rows.
+    mw::E<void> cleanupAuthentication(std::int64_t now) override;
+
     /// Return a card's game-owned display fields.
     mw::E<std::vector<DisplayField>> getGameDisplayFields(
         const GameDefinition& game,
diff --git a/src/email_address.cpp b/src/email_address.cpp
new file mode 100644
index 0000000..f8e5663
--- /dev/null
+++ b/src/email_address.cpp
@@ -0,0 +1,101 @@
+#include "email_address.h"
+
+#include <algorithm>
+#include <cctype>
+#include <ranges>
+#include <string>
+#include <string_view>
+
+namespace
+{
+
+bool isAsciiWhitespace(unsigned char value)
+{
+    return value == ' ' || value == '\t' || value == '\n' ||
+           value == '\r' || value == '\f' || value == '\v';
+}
+
+bool isLocalAtom(unsigned char value)
+{
+    return std::isalnum(value) != 0 ||
+           std::string_view("!#$%&'*+-/=?^_`{|}~").find(
+               static_cast<char>(value)) != std::string_view::npos;
+}
+
+bool isDomainLabel(const std::string& label)
+{
+    if(label.empty() || label.size() > 63 || label.front() == '-' ||
+       label.back() == '-')
+    {
+        return false;
+    }
+    return std::ranges::all_of(label, [](unsigned char value)
+    {
+        return std::isalnum(value) != 0 || value == '-';
+    });
+}
+
+} // namespace
+
+mw::E<EmailAddress> normalizeEmail(const std::string& input)
+{
+    const auto first = std::ranges::find_if_not(input, isAsciiWhitespace);
+    const auto last = std::ranges::find_if_not(
+        input | std::views::reverse, isAsciiWhitespace).base();
+    const std::string email = first < last ? std::string(first, last) : "";
+    if(email.empty() || email.size() > 254)
+    {
+        return std::unexpected(mw::runtimeError("Invalid email address"));
+    }
+    for(unsigned char value : email)
+    {
+        if(value > 0x7f || value == 0 || value < 0x20 || value == 0x7f)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Invalid email address"));
+        }
+    }
+
+    const std::size_t at = email.find('@');
+    if(at == std::string::npos || at == 0 ||
+       at != email.rfind('@') || at > 64 || at + 1 == email.size())
+    {
+        return std::unexpected(mw::runtimeError("Invalid email address"));
+    }
+    const std::string local = email.substr(0, at);
+    const std::string domain = email.substr(at + 1);
+    if(domain.size() > 253 || local.front() == '.' || local.back() == '.' ||
+       local.find("..") != std::string::npos ||
+       !std::ranges::all_of(local, [](unsigned char value)
+       {
+           return value == '.' || isLocalAtom(value);
+       }))
+    {
+        return std::unexpected(mw::runtimeError("Invalid email address"));
+    }
+
+    std::size_t begin = 0;
+    while(begin <= domain.size())
+    {
+        const std::size_t end = domain.find('.', begin);
+        const std::string label = domain.substr(
+            begin, end == std::string::npos ? end : end - begin);
+        if(!isDomainLabel(label))
+        {
+            return std::unexpected(mw::runtimeError(
+                "Invalid email address"));
+        }
+        if(end == std::string::npos)
+        {
+            break;
+        }
+        begin = end + 1;
+    }
+
+    std::string key = email;
+    std::ranges::transform(key, key.begin(), [](unsigned char value)
+    {
+        return static_cast<char>(std::tolower(value));
+    });
+    return EmailAddress{email, std::move(key)};
+}
diff --git a/src/email_address.h b/src/email_address.h
new file mode 100644
index 0000000..c60db90
--- /dev/null
+++ b/src/email_address.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Validated email spelling and normalized identity key.
+struct EmailAddress
+{
+    /// Trimmed ASCII address retained for delivery.
+    std::string email;
+
+    /// Lowercase identity used for uniqueness and rate limiting.
+    std::string key;
+};
+
+/// Validate and normalize an MVP ASCII email address.
+mw::E<EmailAddress> normalizeEmail(const std::string& input);
diff --git a/src/email_sender.h b/src/email_sender.h
new file mode 100644
index 0000000..8488e83
--- /dev/null
+++ b/src/email_sender.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <chrono>
+#include <string>
+
+#include <mw/error.hpp>
+#include <mw/url.hpp>
+
+/// Contents needed to send one passwordless authentication email.
+struct AuthenticationEmail
+{
+    /// Validated recipient address.
+    std::string recipient;
+
+    /// Absolute single-use confirmation URL.
+    mw::URL confirmation_url;
+
+    /// Challenge expiration instant.
+    std::chrono::system_clock::time_point expires_at;
+};
+
+/// Configured delivery mechanism for passwordless authentication links.
+class EmailSenderInterface
+{
+public:
+    virtual ~EmailSenderInterface() = default;
+
+    /// Deliver one authentication link or return a non-secret error.
+    virtual mw::E<void> send(const AuthenticationEmail& email) = 0;
+
+    /// Return whether sends must reserve the global production quota.
+    virtual bool usesGlobalQuota() const = 0;
+};
diff --git a/src/email_sender_file.cpp b/src/email_sender_file.cpp
new file mode 100644
index 0000000..f6b60bc
--- /dev/null
+++ b/src/email_sender_file.cpp
@@ -0,0 +1,76 @@
+#include "email_sender_file.h"
+
+#include <atomic>
+#include <cerrno>
+#include <cstdint>
+#include <cstring>
+#include <filesystem>
+#include <fcntl.h>
+#include <string>
+#include <system_error>
+#include <unistd.h>
+
+FileEmailSender::FileEmailSender(std::filesystem::path link_file)
+    : link_file_(std::move(link_file))
+{}
+
+mw::E<void> FileEmailSender::send(const AuthenticationEmail& email)
+{
+    static std::atomic<std::uint64_t> next_temporary_id = 0;
+    const std::uint64_t suffix = next_temporary_id.fetch_add(
+        1, std::memory_order_relaxed);
+    std::filesystem::path temporary = link_file_;
+    temporary += ".tmp-" + std::to_string(::getpid()) + '-' +
+        std::to_string(suffix);
+
+    const int file = ::open(
+        temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600);
+    if(file < 0)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to write authentication link"));
+    }
+    const std::string contents = email.confirmation_url.str() + "\n";
+    std::size_t written = 0;
+    while(written < contents.size())
+    {
+        const ssize_t count = ::write(
+            file, contents.data() + written, contents.size() - written);
+        if(count < 0 && errno == EINTR)
+        {
+            continue;
+        }
+        if(count <= 0)
+        {
+            ::close(file);
+            std::error_code ignored;
+            std::filesystem::remove(temporary, ignored);
+            return std::unexpected(mw::runtimeError(
+                "Failed to write authentication link"));
+        }
+        written += static_cast<std::size_t>(count);
+    }
+    if(::fsync(file) != 0 || ::close(file) != 0)
+    {
+        std::error_code ignored;
+        std::filesystem::remove(temporary, ignored);
+        return std::unexpected(mw::runtimeError(
+            "Failed to write authentication link"));
+    }
+
+    std::error_code error;
+    std::filesystem::rename(temporary, link_file_, error);
+    if(error)
+    {
+        std::error_code ignored;
+        std::filesystem::remove(temporary, ignored);
+        return std::unexpected(mw::runtimeError(
+            "Failed to publish authentication link"));
+    }
+    return {};
+}
+
+bool FileEmailSender::usesGlobalQuota() const
+{
+    return false;
+}
diff --git a/src/email_sender_file.h b/src/email_sender_file.h
new file mode 100644
index 0000000..2b342ed
--- /dev/null
+++ b/src/email_sender_file.h
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <filesystem>
+
+#include "email_sender.h"
+
+/// Development sender that atomically publishes the latest link to a file.
+class FileEmailSender final : public EmailSenderInterface
+{
+public:
+    /// Store the absolute target whose existing parent receives temp files.
+    explicit FileEmailSender(std::filesystem::path link_file);
+
+    /// Atomically write the latest absolute confirmation URL.
+    mw::E<void> send(const AuthenticationEmail& email) override;
+
+    /// File delivery does not consume the Mailjet quota.
+    bool usesGlobalQuota() const override;
+
+private:
+    std::filesystem::path link_file_;
+};
diff --git a/src/email_sender_mailjet.cpp b/src/email_sender_mailjet.cpp
new file mode 100644
index 0000000..3a595ad
--- /dev/null
+++ b/src/email_sender_mailjet.cpp
@@ -0,0 +1,176 @@
+#include "email_sender_mailjet.h"
+
+#include <chrono>
+#include <cstddef>
+#include <memory>
+#include <span>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+#include <mw/http_client.hpp>
+#include <mw/utils.hpp>
+
+namespace
+{
+
+constexpr std::string_view MAILJET_SEND_URL =
+    "https://api.mailjet.com/v3.1/send";
+
+std::string htmlEscape(const std::string& input)
+{
+    std::string result;
+    result.reserve(input.size());
+    for(char value : input)
+    {
+        switch(value)
+        {
+        case '&':
+            result += "&amp;";
+            break;
+        case '<':
+            result += "&lt;";
+            break;
+        case '>':
+            result += "&gt;";
+            break;
+        case '"':
+            result += "&quot;";
+            break;
+        case '\'':
+            result += "&#39;";
+            break;
+        default:
+            result.push_back(value);
+        }
+    }
+    return result;
+}
+
+std::string basicAuthorization(
+    const std::string& api_key, const std::string& secret_key)
+{
+    const std::string credentials = api_key + ':' + secret_key;
+    std::vector<unsigned char> bytes(
+        credentials.begin(), credentials.end());
+    return "Basic " + mw::base64Encode(
+        std::span<unsigned char>(bytes), false, true);
+}
+
+} // namespace
+
+MailjetEmailSender::MailjetEmailSender(
+    std::unique_ptr<mw::HTTPSessionInterface> session,
+    std::string from_address,
+    std::string from_name,
+    std::string api_key,
+    std::string secret_key)
+    : session_(std::move(session)),
+      from_address_(std::move(from_address)),
+      from_name_(std::move(from_name)),
+      api_key_(std::move(api_key)),
+      secret_key_(std::move(secret_key))
+{}
+
+mw::E<void> MailjetEmailSender::configure()
+{
+    auto protocols = session_->allowedProtocols("https");
+    if(!protocols)
+    {
+        return std::unexpected(std::move(protocols.error()));
+    }
+    auto redirect_protocols = session_->allowedRedirectProtocols("https");
+    if(!redirect_protocols)
+    {
+        return std::unexpected(std::move(redirect_protocols.error()));
+    }
+    session_->followRedirects(false);
+    auto redirections = session_->maxRedirections(0);
+    if(!redirections)
+    {
+        return std::unexpected(std::move(redirections.error()));
+    }
+    auto maximum = session_->maxSize(64 * 1024);
+    if(!maximum)
+    {
+        return std::unexpected(std::move(maximum.error()));
+    }
+    auto connection_timeout = session_->connectionTimeout(
+        std::chrono::seconds(5));
+    if(!connection_timeout)
+    {
+        return std::unexpected(std::move(connection_timeout.error()));
+    }
+    return session_->transferTimeout(std::chrono::seconds(15));
+}
+
+mw::E<void> MailjetEmailSender::send(const AuthenticationEmail& email)
+{
+    const std::string link = email.confirmation_url.str();
+    const std::string text =
+        "Use this link to sign in to Card Collection. The link expires in "
+        "ten minutes:\n\n" + link +
+        "\n\nIf you did not request this message, ignore it.";
+    const std::string html =
+        "<p>Use this link to sign in to Card Collection. The link expires "
+        "in ten minutes:</p><p><a href=\"" + htmlEscape(link) +
+        "\">Sign in</a></p><p>If you did not request this message, "
+        "ignore it.</p>";
+    nlohmann::json body = {
+        {"Messages", nlohmann::json::array({
+            {
+                {"From", {
+                    {"Email", from_address_},
+                    {"Name", from_name_},
+                }},
+                {"To", nlohmann::json::array({{
+                    {"Email", email.recipient},
+                }})},
+                {"Subject", "Sign in to Card Collection"},
+                {"TextPart", text},
+                {"HTMLPart", html},
+            },
+        })},
+    };
+    mw::HTTPRequest request(MAILJET_SEND_URL);
+    request.setContentType("application/json");
+    request.setPayload(body.dump());
+    request.addHeader(
+        "Authorization", basicAuthorization(api_key_, secret_key_));
+    auto response = session_->post(request);
+    if(!response)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Mailjet request failed"));
+    }
+    if((**response).status < 200 || (**response).status >= 300)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Mailjet rejected the authentication email"));
+    }
+    try
+    {
+        const nlohmann::json parsed = nlohmann::json::parse(
+            (**response).payloadAsStr());
+        if(!parsed.contains("Messages") ||
+           !parsed["Messages"].is_array() ||
+           parsed["Messages"].size() != 1 ||
+           parsed["Messages"][0].value("Status", "") != "success")
+        {
+            return std::unexpected(mw::runtimeError(
+                "Mailjet returned an unsuccessful message status"));
+        }
+    }
+    catch(const nlohmann::json::exception&)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Mailjet returned an invalid response"));
+    }
+    return {};
+}
+
+bool MailjetEmailSender::usesGlobalQuota() const
+{
+    return true;
+}
diff --git a/src/email_sender_mailjet.h b/src/email_sender_mailjet.h
new file mode 100644
index 0000000..e6e4db0
--- /dev/null
+++ b/src/email_sender_mailjet.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include <mw/error.hpp>
+#include <mw/http_client.hpp>
+
+#include "email_sender.h"
+
+/// Production authentication email sender using Mailjet Send API v3.1.
+class MailjetEmailSender final : public EmailSenderInterface
+{
+public:
+    /// Adopt an HTTP session and copy configured sender credentials.
+    MailjetEmailSender(
+        std::unique_ptr<mw::HTTPSessionInterface> session,
+        std::string from_address,
+        std::string from_name,
+        std::string api_key,
+        std::string secret_key);
+
+    /// Apply HTTPS-only, redirect, size, and timeout restrictions.
+    mw::E<void> configure();
+
+    /// Deliver one authentication message through Mailjet.
+    mw::E<void> send(const AuthenticationEmail& email) override;
+
+    /// Mailjet calls consume the persistent global quota.
+    bool usesGlobalQuota() const override;
+
+private:
+    std::unique_ptr<mw::HTTPSessionInterface> session_;
+    std::string from_address_;
+    std::string from_name_;
+    std::string api_key_;
+    std::string secret_key_;
+};
diff --git a/src/main.cpp b/src/main.cpp
index f5de9a2..18ce9e3 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -9,8 +9,10 @@
 
 #include <Magick++.h>
 #include <spdlog/spdlog.h>
+#include <uni_algo/version.h>
 
 #include "app.h"
+#include "clock.h"
 #include "game_registry.h"
 #include "startup.h"
 
@@ -98,9 +100,27 @@ int main(int argc, char** argv)
             image_magick.error().msg());
         return 1;
     }
+    spdlog::info(
+        "Username normalization uses uni-algo {}.{}.{} and Unicode {}.{}.{}",
+        una::version::library.major(),
+        una::version::library.minor(),
+        una::version::library.patch(),
+        una::version::unicode.major(),
+        una::version::unicode.minor(),
+        una::version::unicode.update());
 
     auto games = std::make_unique<GameRegistry>();
-    auto data_source = prepareDataSource(config->database_path, *games);
+    const auto startup_time = std::chrono::system_clock::now();
+    const std::int64_t startup_seconds =
+        std::chrono::duration_cast<std::chrono::seconds>(
+            startup_time.time_since_epoch()).count();
+    auto data_source = prepareDataSource(
+        config->database_path,
+        *games,
+        config->administrator_email,
+        config->administrator_email_key,
+        startup_seconds,
+        utcDay(startup_time));
     if(!data_source)
     {
         spdlog::error(
@@ -119,31 +139,40 @@ int main(int argc, char** argv)
         return 1;
     }
 
-    App app(
-        *config,
-        std::move(*data_source),
-        std::move(games),
-        std::make_unique<NonSecretRandom>());
+    try
+    {
+        App app(
+            *config,
+            std::move(*data_source),
+            std::move(games),
+            std::make_unique<NonSecretRandom>());
 
-    std::signal(SIGINT, handleSignal);
-    std::signal(SIGTERM, handleSignal);
+        std::signal(SIGINT, handleSignal);
+        std::signal(SIGTERM, handleSignal);
 
-    auto start_result = app.start();
-    if(!start_result)
+        auto start_result = app.start();
+        if(!start_result)
+        {
+            spdlog::error(
+                "Failed to start the server: {}",
+                start_result.error().msg());
+            return 1;
+        }
+
+        spdlog::info("Server listening at {}", config->base_url.str());
+        while(STOP_REQUESTED == 0)
+        {
+            std::this_thread::sleep_for(std::chrono::milliseconds(100));
+        }
+
+        app.stop();
+        app.wait();
+    }
+    catch(const std::exception& error)
     {
         spdlog::error(
-            "Failed to start the server: {}",
-            start_result.error().msg());
+            "Failed to construct application services: {}", error.what());
         return 1;
     }
-
-    spdlog::info("Server listening at {}", config->base_url.str());
-    while(STOP_REQUESTED == 0)
-    {
-        std::this_thread::sleep_for(std::chrono::milliseconds(100));
-    }
-
-    app.stop();
-    app.wait();
     return 0;
 }
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index c5cdb10..4744426 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -41,6 +41,7 @@ bool isTextField(std::string_view name)
         "foil_action",
         "revision",
         "series_id",
+        "csrf_token",
     };
     return names.contains(std::string(name));
 }
diff --git a/src/secret_token.cpp b/src/secret_token.cpp
new file mode 100644
index 0000000..647f82d
--- /dev/null
+++ b/src/secret_token.cpp
@@ -0,0 +1,96 @@
+#include "secret_token.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <string>
+
+namespace
+{
+
+constexpr std::array<char, 16> HEX = {
+    '0', '1', '2', '3', '4', '5', '6', '7',
+    '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+
+mw::E<std::string> decodeToken(const std::string& token)
+{
+    if(token.size() != 64)
+    {
+        return std::unexpected(mw::runtimeError("Invalid token"));
+    }
+    std::string bytes(32, '\0');
+    for(std::size_t index = 0; index < bytes.size(); ++index)
+    {
+        const auto nibble = [](char value) -> int
+        {
+            if(value >= '0' && value <= '9')
+            {
+                return value - '0';
+            }
+            if(value >= 'a' && value <= 'f')
+            {
+                return value - 'a' + 10;
+            }
+            return -1;
+        };
+        const int high = nibble(token[index * 2]);
+        const int low = nibble(token[index * 2 + 1]);
+        if(high < 0 || low < 0)
+        {
+            return std::unexpected(mw::runtimeError("Invalid token"));
+        }
+        bytes[index] = static_cast<char>((high << 4) | low);
+    }
+    return bytes;
+}
+
+} // namespace
+
+mw::E<SecretToken> generateSecretToken(mw::CryptoInterface& crypto)
+{
+    auto random = crypto.randomBytes(32);
+    if(!random)
+    {
+        return std::unexpected(std::move(random.error()));
+    }
+    std::string bytes;
+    bytes.reserve(random->size());
+    std::string value;
+    value.reserve(random->size() * 2);
+    for(std::byte byte : *random)
+    {
+        const unsigned int number = std::to_integer<unsigned int>(byte);
+        bytes.push_back(static_cast<char>(number));
+        value.push_back(HEX[number >> 4]);
+        value.push_back(HEX[number & 0x0f]);
+    }
+    auto hash = mw::SHA256Hasher().hashToBytes(bytes);
+    if(!hash)
+    {
+        return std::unexpected(std::move(hash.error()));
+    }
+    return SecretToken{std::move(value), std::move(*hash)};
+}
+
+mw::E<std::vector<unsigned char>> hashSecretToken(const std::string& token)
+{
+    auto bytes = decodeToken(token);
+    if(!bytes)
+    {
+        return std::unexpected(std::move(bytes.error()));
+    }
+    return mw::SHA256Hasher().hashToBytes(*bytes);
+}
+
+bool constantTimeEqual(const std::string& first, const std::string& second)
+{
+    const std::size_t length = std::max(first.size(), second.size());
+    std::size_t difference = first.size() ^ second.size();
+    for(std::size_t index = 0; index < length; ++index)
+    {
+        const unsigned char left = index < first.size() ? first[index] : 0;
+        const unsigned char right = index < second.size() ? second[index] : 0;
+        difference |= static_cast<std::size_t>(left ^ right);
+    }
+    return difference == 0;
+}
diff --git a/src/secret_token.h b/src/secret_token.h
new file mode 100644
index 0000000..924a57f
--- /dev/null
+++ b/src/secret_token.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <string>
+#include <vector>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+
+/// Raw credential returned once and its SHA-256 digest for persistence.
+struct SecretToken
+{
+    /// Lowercase hexadecimal credential for a URL or cookie.
+    std::string value;
+
+    /// SHA-256 digest of the original random bytes.
+    std::vector<unsigned char> hash;
+};
+
+/// Generate one 256-bit credential and persistence digest.
+mw::E<SecretToken> generateSecretToken(mw::CryptoInterface& crypto);
+
+/// Validate, decode, and hash a presented credential.
+mw::E<std::vector<unsigned char>> hashSecretToken(const std::string& token);
+
+/// Compare equally sized secret strings without early exit.
+bool constantTimeEqual(const std::string& first, const std::string& second);
diff --git a/src/series_service.cpp b/src/series_service.cpp
index 61e277a..50fd71c 100644
--- a/src/series_service.cpp
+++ b/src/series_service.cpp
@@ -35,6 +35,24 @@ mw::E<void> rejectDuplicate(
     return {};
 }
 
+mw::E<void> authorizeAdministrator(
+    DataSourceTransactionInterface& transaction,
+    const AuthorizationService& authorization,
+    std::int64_t actor_user_id)
+{
+    auto actor = transaction.getUserForUpdate(actor_user_id);
+    if(!actor)
+    {
+        return std::unexpected(std::move(actor.error()));
+    }
+    if(!*actor || !(*actor)->username ||
+       !authorization.canAdminister(**actor))
+    {
+        return std::unexpected(mw::httpError(403, "Administrator required"));
+    }
+    return {};
+}
+
 } // namespace
 
 SeriesService::SeriesService(
@@ -45,6 +63,7 @@ SeriesService::SeriesService(
 {}
 
 mw::E<std::int64_t> SeriesService::create(
+    std::int64_t actor_user_id,
     std::string game_short_name,
     std::string name,
     std::string description)
@@ -83,6 +102,12 @@ mw::E<std::int64_t> SeriesService::create(
     {
         return std::unexpected(std::move(transaction.error()));
     }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
     auto id = (*transaction)->insertSeries(series);
     if(!id)
     {
@@ -97,6 +122,7 @@ mw::E<std::int64_t> SeriesService::create(
 }
 
 mw::E<void> SeriesService::update(
+    std::int64_t actor_user_id,
     std::int64_t series_id,
     std::string name,
     std::string description)
@@ -136,6 +162,12 @@ mw::E<void> SeriesService::update(
     {
         return std::unexpected(std::move(transaction.error()));
     }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
     auto updated = (*transaction)->updateSeries(series);
     if(!updated)
     {
@@ -144,7 +176,9 @@ mw::E<void> SeriesService::update(
     return (*transaction)->commit();
 }
 
-mw::E<void> SeriesService::remove(std::int64_t series_id)
+mw::E<void> SeriesService::remove(
+    std::int64_t actor_user_id,
+    std::int64_t series_id)
 {
     auto current = data_source_.getSeries(series_id);
     if(!current)
@@ -160,6 +194,12 @@ mw::E<void> SeriesService::remove(std::int64_t series_id)
     {
         return std::unexpected(std::move(transaction.error()));
     }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
     auto deleted = (*transaction)->deleteSeries(series_id);
     if(!deleted)
     {
diff --git a/src/series_service.h b/src/series_service.h
index 562cab7..c05da3c 100644
--- a/src/series_service.h
+++ b/src/series_service.h
@@ -5,6 +5,7 @@
 
 #include <mw/error.hpp>
 
+#include "authorization.h"
 #include "data.h"
 #include "game_registry.h"
 #include "markdown_renderer.h"
@@ -20,21 +21,26 @@ public:
 
     /// Create a series and return its internal ID.
     mw::E<std::int64_t> create(
+        std::int64_t actor_user_id,
         std::string game_short_name,
         std::string name,
         std::string description);
 
     /// Edit a series without changing its owning game.
     mw::E<void> update(
+        std::int64_t actor_user_id,
         std::int64_t series_id,
         std::string name,
         std::string description);
 
     /// Delete a series and its membership rows.
-    mw::E<void> remove(std::int64_t series_id);
+    mw::E<void> remove(
+        std::int64_t actor_user_id,
+        std::int64_t series_id);
 
 private:
     DataSourceInterface& data_source_;
     const GameRegistry& games_;
+    AuthorizationService authorization_;
     MarkdownRenderer markdown_renderer_;
 };
diff --git a/src/startup.cpp b/src/startup.cpp
index ba820f0..e66d64c 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -2,13 +2,19 @@
 
 #include <utility>
 
+#include <spdlog/spdlog.h>
+
 #include "data_sqlite.h"
 #include "game_definition.h"
 #include "game_registry.h"
 
 mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
     const std::filesystem::path& database_path,
-    const GameRegistry& games)
+    const GameRegistry& games,
+    const std::string& administrator_email,
+    const std::string& administrator_email_key,
+    std::int64_t now,
+    std::int64_t utc_day)
 {
     auto data_source = DataSourceSQLite::fromFile(database_path);
     if(!data_source)
@@ -21,6 +27,23 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
         return std::unexpected(std::move(migration_result.error()));
     }
 
+    auto administrator = (*data_source)->reconcileAdministrator(
+        administrator_email,
+        administrator_email_key,
+        now,
+        utc_day);
+    if(!administrator)
+    {
+        return std::unexpected(std::move(administrator.error()));
+    }
+    auto cleaned = (*data_source)->cleanupAuthentication(now);
+    if(!cleaned)
+    {
+        spdlog::warn(
+            "Best-effort authentication cleanup failed: {}",
+            cleaned.error().msg());
+    }
+
     auto transaction = (*data_source)->beginTransaction();
     if(!transaction)
     {
diff --git a/src/startup.h b/src/startup.h
index d6d46cb..2f20502 100644
--- a/src/startup.h
+++ b/src/startup.h
@@ -1,7 +1,9 @@
 #pragma once
 
 #include <filesystem>
+#include <cstdint>
 #include <memory>
+#include <string>
 
 #include <mw/error.hpp>
 
@@ -10,4 +12,8 @@
 /// Open and migrate the application data source before server startup.
 mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
     const std::filesystem::path& database_path,
-    const GameRegistry& games);
+    const GameRegistry& games,
+    const std::string& administrator_email = "admin@example.com",
+    const std::string& administrator_email_key = "admin@example.com",
+    std::int64_t now = 0,
+    std::int64_t utc_day = 0);
diff --git a/src/user.h b/src/user.h
new file mode 100644
index 0000000..a665074
--- /dev/null
+++ b/src/user.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+/// Permission level assigned to one user account.
+enum class UserRole
+{
+    PLAYER = 0,
+    CREATOR = 1,
+    ADMINISTRATOR = 2
+};
+
+/// Persisted application user and lazily accrued pull state.
+struct User
+{
+    /// Internal SQLite identity.
+    std::int64_t id;
+
+    /// Immutable validated delivery address.
+    std::string email;
+
+    /// Normalized unique email identity.
+    std::string email_key;
+
+    /// NFC username, or null until onboarding completes.
+    std::optional<std::string> username;
+
+    /// Current permission level.
+    UserRole role;
+
+    /// Pulls retained at the last refresh.
+    std::uint32_t stored_pulls;
+
+    /// UTC epoch day at the last refresh.
+    std::int64_t pull_refresh_day;
+
+    /// Unix timestamp at account creation.
+    std::int64_t created_at;
+};
diff --git a/src/user_service.cpp b/src/user_service.cpp
new file mode 100644
index 0000000..ad698ba
--- /dev/null
+++ b/src/user_service.cpp
@@ -0,0 +1,106 @@
+#include "user_service.h"
+
+#include <utility>
+
+#include "username.h"
+
+UserService::UserService(DataSourceInterface& data_source)
+    : data_source_(data_source)
+{}
+
+mw::E<User> UserService::setUsername(
+    std::int64_t user_id, const std::string& submitted_username)
+{
+    auto normalized = normalizeUsername(submitted_username);
+    if(!normalized)
+    {
+        return std::unexpected(mw::httpError(400, "Invalid username"));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto user = (*transaction)->getUserForUpdate(user_id);
+    if(!user)
+    {
+        return std::unexpected(std::move(user.error()));
+    }
+    if(!*user)
+    {
+        return std::unexpected(mw::httpError(404, "User not found"));
+    }
+    auto updated = (*transaction)->updateUsername(
+        user_id, normalized->username, normalized->key);
+    if(!updated)
+    {
+        return std::unexpected(mw::httpError(
+            409, "Username is already in use"));
+    }
+    if(!*updated)
+    {
+        return std::unexpected(mw::httpError(404, "User not found"));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    (**user).username = std::move(normalized->username);
+    return std::move(**user);
+}
+
+mw::E<User> UserService::promote(
+    std::int64_t administrator_user_id,
+    std::int64_t target_user_id)
+{
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto administrator = (*transaction)->getUserForUpdate(
+        administrator_user_id);
+    if(!administrator)
+    {
+        return std::unexpected(std::move(administrator.error()));
+    }
+    if(!*administrator ||
+       (**administrator).role != UserRole::ADMINISTRATOR)
+    {
+        return std::unexpected(mw::httpError(403, "Forbidden"));
+    }
+    auto target = (*transaction)->getUserForUpdate(target_user_id);
+    if(!target)
+    {
+        return std::unexpected(std::move(target.error()));
+    }
+    if(!*target)
+    {
+        return std::unexpected(mw::httpError(404, "User not found"));
+    }
+    if((**target).role == UserRole::ADMINISTRATOR)
+    {
+        return std::unexpected(mw::httpError(409, "Role cannot be changed"));
+    }
+    if((**target).role == UserRole::PLAYER)
+    {
+        auto promoted = (*transaction)->promoteUser(target_user_id);
+        if(!promoted)
+        {
+            return std::unexpected(std::move(promoted.error()));
+        }
+        if(!*promoted)
+        {
+            return std::unexpected(mw::httpError(
+                409, "User role changed in another request"));
+        }
+        (**target).role = UserRole::CREATOR;
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return std::move(**target);
+}
diff --git a/src/user_service.h b/src/user_service.h
new file mode 100644
index 0000000..2490cdf
--- /dev/null
+++ b/src/user_service.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include <mw/error.hpp>
+
+#include "data.h"
+
+/// Own username onboarding, username changes, and creator promotion.
+class UserService
+{
+public:
+    /// Construct a service over the application persistence boundary.
+    explicit UserService(DataSourceInterface& data_source);
+
+    /// Set or change one user's validated Unicode username.
+    mw::E<User> setUsername(
+        std::int64_t user_id, const std::string& submitted_username);
+
+    /// Permanently promote one player after re-reading the administrator.
+    mw::E<User> promote(
+        std::int64_t administrator_user_id,
+        std::int64_t target_user_id);
+
+private:
+    DataSourceInterface& data_source_;
+};
diff --git a/src/username.cpp b/src/username.cpp
new file mode 100644
index 0000000..1adfd33
--- /dev/null
+++ b/src/username.cpp
@@ -0,0 +1,39 @@
+#include "username.h"
+
+#include <string>
+
+#include <uni_algo/case.h>
+#include <uni_algo/conv.h>
+#include <uni_algo/norm.h>
+#include <uni_algo/prop.h>
+
+mw::E<NormalizedUsername> normalizeUsername(const std::string& input)
+{
+    if(!una::is_valid_utf8(input))
+    {
+        return std::unexpected(mw::runtimeError("Invalid username"));
+    }
+    std::string username = una::norm::to_nfc_utf8(input);
+    if(username.empty() || username.size() > 32)
+    {
+        return std::unexpected(mw::runtimeError("Invalid username"));
+    }
+
+    const std::u32string codepoints = una::utf8to32u(username);
+    for(char32_t codepoint : codepoints)
+    {
+        if(una::codepoint::prop(codepoint).General_Category_Cc())
+        {
+            return std::unexpected(mw::runtimeError("Invalid username"));
+        }
+    }
+    if(una::codepoint::prop(codepoints.front()).White_Space() ||
+       una::codepoint::prop(codepoints.back()).White_Space())
+    {
+        return std::unexpected(mw::runtimeError("Invalid username"));
+    }
+
+    std::string key = una::cases::to_casefold_utf8(username);
+    key = una::norm::to_nfc_utf8(key);
+    return NormalizedUsername{std::move(username), std::move(key)};
+}
diff --git a/src/username.h b/src/username.h
new file mode 100644
index 0000000..7089b0c
--- /dev/null
+++ b/src/username.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Validated NFC username and normalized uniqueness key.
+struct NormalizedUsername
+{
+    /// NFC display value.
+    std::string username;
+
+    /// Full-case-folded NFC uniqueness key.
+    std::string key;
+};
+
+/// Validate and normalize a submitted UTF-8 username.
+mw::E<NormalizedUsername> normalizeUsername(const std::string& input);
diff --git a/static/css/styles.css b/static/css/styles.css
index 1323c00..6f0ad7a 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1013,3 +1013,31 @@ h1 {
         transition-duration: 0.01ms !important;
     }
 }
+
+.collection-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+    gap: 1rem;
+    padding: 0;
+    list-style: none;
+}
+
+.collection-card a {
+    position: relative;
+    display: grid;
+    gap: 0.5rem;
+}
+
+.collection-card img {
+    width: 100%;
+}
+
+.collection-quantity {
+    position: absolute;
+    right: 0.5rem;
+    bottom: 2rem;
+    padding: 0.2rem 0.5rem;
+    border-radius: 999px;
+    color: white;
+    background: rgb(0 0 0 / 75%);
+}
diff --git a/templates/account.html b/templates/account.html
new file mode 100644
index 0000000..6a81447
--- /dev/null
+++ b/templates/account.html
@@ -0,0 +1,13 @@
+{% extends "layout.html" %}
+{% block content %}
+<section class="page-shell form-page">
+    <h1>Account</h1>
+    <dl><dt>Email</dt><dd>{{ email }}</dd>
+        <dt>Username</dt><dd>{{ username }}</dd></dl>
+    <p><a href="{{ username_url }}">Change username</a></p>
+    <form method="post" action="{{ logout_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <button>Sign out</button>
+    </form>
+</section>
+{% endblock %}
diff --git a/templates/authentication.html b/templates/authentication.html
new file mode 100644
index 0000000..bddafcb
--- /dev/null
+++ b/templates/authentication.html
@@ -0,0 +1,9 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Sign in or register</h1>
+<form method="post" action="{{ action_url }}">
+    <label>Email <input type="email" name="email" required></label>
+    <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
+    <button>Send sign-in link</button>
+</form>
+{% endblock %}
diff --git a/templates/authentication_confirm.html b/templates/authentication_confirm.html
new file mode 100644
index 0000000..a6dd214
--- /dev/null
+++ b/templates/authentication_confirm.html
@@ -0,0 +1,9 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Confirm sign in</h1>
+<p>Continue as {{ email }}?</p>
+<form method="post" action="{{ action_url }}">
+    <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
+    <button>Continue</button>
+</form>
+{% endblock %}
diff --git a/templates/authentication_sent.html b/templates/authentication_sent.html
new file mode 100644
index 0000000..fee6e10
--- /dev/null
+++ b/templates/authentication_sent.html
@@ -0,0 +1,5 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Check your email</h1>
+<p>If the address can receive mail, a sign-in link is on its way.</p>
+{% endblock %}
diff --git a/templates/card_delete.html b/templates/card_delete.html
index 582929f..e45be73 100644
--- a/templates/card_delete.html
+++ b/templates/card_delete.html
@@ -7,6 +7,7 @@
     <h1 id="DeleteCardHeading">Delete {{ name }}?</h1>
     <p>This permanently removes the card and its published images.</p>
     <form class="standalone-form" method="post" action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
         <button class="form-submit destructive-submit" type="submit">
             Delete card
         </button>
diff --git a/templates/card_form.html b/templates/card_form.html
index 3618fee..b9d9d0d 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -20,6 +20,7 @@
 
         <form id="CardForm" class="card-form" method="post"
               action="{{ action_url }}" enctype="multipart/form-data">
+            <input name="csrf_token" type="hidden" value="{{ csrf_token }}">
             <section class="form-section" aria-labelledby="IdentityHeading">
                 <h2 id="IdentityHeading">Identity</h2>
                 {% if mode == "edit" %}
@@ -46,11 +47,16 @@
                            maxlength="200" autocomplete="off"
                            value="{{ name }}" required>
                 </label>
+                {% if show_rarity %}
                 <label class="form-field" for="CardRarity">
                     <span>Rarity</span>
                     <input id="CardRarity" name="rarity" type="number"
                            min="0" step="1" value="{{ rarity }}" required>
                 </label>
+                {% else %}
+                <p>Rarity is assigned by an administrator before this card
+                   enters the pull pool.</p>
+                {% endif %}
             </section>
 
             {% for game in games %}
diff --git a/templates/card_index.html b/templates/card_index.html
index 0376090..be3741e 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -15,6 +15,12 @@
     </div>
 </div>
 
+<p><a href="{{ create_url }}">Create card</a>
+{% if administrator %}
+ · <a href="{{ series_url }}">Manage series</a>
+ · <a href="{{ users_url }}">Manage users</a>
+{% endif %}</p>
+
 {% if length(cards) == 0 %}
 <p class="empty-state">No cards yet.</p>
 {% endif %}
diff --git a/templates/card_view.html b/templates/card_view.html
index 32889d2..786cc28 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -43,6 +43,10 @@
                 <dt>Rarity</dt>
                 <dd>{{ rarity }}</dd>
             </div>
+            <div>
+                <dt>Pull probability</dt>
+                <dd>{{ probability }}</dd>
+            </div>
             <div>
                 <dt>Finish</dt>
                 <dd>{% if has_foil %}Foil{% else %}Standard{% endif %}</dd>
diff --git a/templates/collection.html b/templates/collection.html
new file mode 100644
index 0000000..6ea6440
--- /dev/null
+++ b/templates/collection.html
@@ -0,0 +1,21 @@
+{% extends "layout.html" %}
+{% block content %}
+<section class="page-shell">
+    <h1>Your collection</h1>
+    <p>Available pulls: {{ available_pulls }}</p>
+    <form method="post" action="{{ pull_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <button {% if pull_disabled %}disabled{% endif %}>Pull a card</button>
+    </form>
+    {% if pool_empty %}<p>The card pool is currently empty.</p>{% endif %}
+    <ul class="collection-grid">
+    {% for entry in entries %}
+        <li class="collection-card"><a href="{{ entry.url }}">
+            <img src="{{ entry.thumbnail_url }}" alt="">
+            <span>{{ entry.name }}</span>
+            <strong class="collection-quantity">{{ entry.quantity }}</strong>
+        </a></li>
+    {% endfor %}
+    </ul>
+</section>
+{% endblock %}
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..6d03cea
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,2 @@
+{% extends "public_layout.html" %}
+{% block content %}<h1>{{ heading }}</h1><p>{{ message }}</p>{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index c7f824a..b34b2e4 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -4,14 +4,6 @@
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>{{ title }}</title>
-    <link rel="preconnect" href="https://fonts.googleapis.com">
-    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-    <link
-        href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400..700"
-        rel="stylesheet">
-    <link
-        href="https://fonts.googleapis.com/css2?family=Nunito:wght@700..900"
-        rel="stylesheet">
     <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
 </head>
 <body>
@@ -21,16 +13,14 @@
         <div class="ambient-blob ambient-blob-blue"></div>
     </div>
     <header class="site-header">
-        <a class="site-title" href="{{ url_for("card-index") }}">
+        <a class="site-title" href="{{ url_for("collection") }}">
             Card Collection
         </a>
         <nav aria-label="Primary navigation">
-            <a class="nav-link" href="{{ url_for("card-index") }}">Cards</a>
-            <a class="nav-link" href="{{ url_for("series-index") }}">
-                Series
+            <a class="nav-link" href="{{ url_for("collection") }}">
+                Collection
             </a>
-            <a class="nav-link nav-link-primary"
-               href="{{ url_for("card-new") }}">Create card</a>
+            <a class="nav-link" href="{{ url_for("account") }}">Account</a>
         </nav>
     </header>
     <main>
diff --git a/templates/onboarding_username.html b/templates/onboarding_username.html
new file mode 100644
index 0000000..f0c1bca
--- /dev/null
+++ b/templates/onboarding_username.html
@@ -0,0 +1,9 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>{{ heading }}</h1>
+<form method="post" action="{{ action_url }}">
+    <input name="username" required maxlength="32" value="{{ username }}">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+    <button>{{ submit_label }}</button>
+</form>
+{% endblock %}
diff --git a/templates/public_layout.html b/templates/public_layout.html
new file mode 100644
index 0000000..ed55a44
--- /dev/null
+++ b/templates/public_layout.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>{{ title }}</title>
+    <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
+</head>
+<body>
+    <main class="page-shell form-page">
+        {% block content %}{% endblock %}
+    </main>
+</body>
+</html>
diff --git a/templates/series_delete.html b/templates/series_delete.html
index f4b025b..d22e355 100644
--- a/templates/series_delete.html
+++ b/templates/series_delete.html
@@ -7,6 +7,7 @@
     <h1 id="DeleteSeriesHeading">Delete {{ name }}?</h1>
     <p>Cards in this series will remain in the collection.</p>
     <form class="standalone-form" method="post" action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
         <button class="form-submit destructive-submit" type="submit">
             Delete series
         </button>
diff --git a/templates/series_form.html b/templates/series_form.html
index 2fc31de..20f6c53 100644
--- a/templates/series_form.html
+++ b/templates/series_form.html
@@ -7,6 +7,7 @@
     <h1 id="SeriesFormHeading">{{ heading }}</h1>
 
     <form class="standalone-form" method="post" action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
         <label class="form-field" for="SeriesGame">
             <span>Game</span>
             {% if mode == "create" %}
diff --git a/templates/user_admin.html b/templates/user_admin.html
new file mode 100644
index 0000000..bf473d0
--- /dev/null
+++ b/templates/user_admin.html
@@ -0,0 +1,20 @@
+{% extends "layout.html" %}
+{% block content %}
+<section class="page-shell">
+    <h1>Users</h1>
+    <table><thead><tr><th>Username</th><th>Email</th><th>Role</th><th></th>
+    </tr></thead><tbody>
+    {% for user in users %}
+    <tr><td>{{ user.username }}</td><td>{{ user.email }}</td>
+        <td>{{ user.role }}</td><td>
+        {% if user.can_promote %}
+        <form method="post" action="{{ user.promote_url }}">
+            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+            <button>Promote</button>
+        </form>
+        {% endif %}
+        </td></tr>
+    {% endfor %}
+    </tbody></table>
+</section>
+{% endblock %}
diff --git a/templates/welcome.html b/templates/welcome.html
new file mode 100644
index 0000000..5aece9b
--- /dev/null
+++ b/templates/welcome.html
@@ -0,0 +1,7 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Card Collection</h1>
+<p>Build your collection one daily pull at a time.</p>
+<img src="{{ example_url }}" alt="Example card">
+<p><a href="{{ authentication_url }}">Sign in or register</a></p>
+{% endblock %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index 86bd9b6..67f5632 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -6,6 +6,7 @@
 #include <iterator>
 #include <memory>
 #include <string>
+#include <string_view>
 
 #include <Magick++.h>
 #include <gtest/gtest.h>
@@ -27,6 +28,58 @@ std::string readFile(const std::filesystem::path& path)
         std::istreambuf_iterator<char>());
 }
 
+std::string hiddenValue(const std::string& body, std::string_view name)
+{
+    std::string marker = "name=\"" + std::string(name) + "\" value=\"";
+    std::size_t begin = body.find(marker);
+    if(begin == std::string::npos)
+    {
+        marker = "name=" + std::string(name) + " value=\"";
+        begin = body.find(marker);
+    }
+    if(begin == std::string::npos)
+    {
+        return {};
+    }
+    const std::size_t value_begin = begin + marker.size();
+    const std::size_t end = body.find('"', value_begin);
+    return end == std::string::npos
+        ? std::string()
+        : body.substr(value_begin, end - value_begin);
+}
+
+std::string responseCookie(
+    const httplib::Response& response, std::string_view name)
+{
+    const std::string prefix = std::string(name) + '=';
+    for(std::size_t index = 0;
+        index < response.get_header_value_count("Set-Cookie");
+        ++index)
+    {
+        const std::string value = response.get_header_value(
+            "Set-Cookie", nullptr, index);
+        if(value.starts_with(prefix))
+        {
+            return value.substr(0, value.find(';'));
+        }
+    }
+    return {};
+}
+
+std::string urlRequestPath(const std::string& url)
+{
+    const std::size_t scheme = url.find("://");
+    const std::size_t path = scheme == std::string::npos
+        ? std::string::npos
+        : url.find('/', scheme + 3);
+    if(path == std::string::npos)
+    {
+        return {};
+    }
+    const std::size_t end = url.find_first_of("\r\n\t ", path);
+    return url.substr(path, end - path);
+}
+
 class TemporaryServerRoot
 {
 public:
@@ -82,6 +135,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         75,
         256,
     };
+    config.email.link_file = temporary.path() / "latest-link.txt";
     auto games = std::make_unique<GameRegistry>();
     ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
     auto data_source = prepareDataSource(config.database_path, *games);
@@ -109,6 +163,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
             std::nullopt,
             "avif",
             1,
+            1,
         },
         game,
         metadata->get(),
@@ -138,35 +193,109 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(index, nullptr);
     EXPECT_EQ(index->status, 200);
     EXPECT_NE(index->body.find("Card Collection"), std::string::npos);
-    auto create = client.Get("/collection/cards/new");
-    ASSERT_NE(create, nullptr);
-    EXPECT_EQ(create->status, 200);
-    auto card = client.Get("/collection/cards/test-1");
+    auto anonymous_card = client.Get("/collection/cards/test-1");
+    ASSERT_NE(anonymous_card, nullptr);
+    EXPECT_EQ(anonymous_card->status, 303);
+
+    auto authentication = client.Get("/collection/authentication");
+    ASSERT_NE(authentication, nullptr);
+    ASSERT_EQ(authentication->status, 200);
+    const std::string authentication_cookie = responseCookie(
+        *authentication, "card_collection_auth_form");
+    const std::string authentication_nonce = hiddenValue(
+        authentication->body, "form_nonce");
+    ASSERT_FALSE(authentication_cookie.empty());
+    ASSERT_FALSE(authentication_nonce.empty());
+    const httplib::Headers authentication_headers = {
+        {"Cookie", authentication_cookie}};
+    const httplib::Params email_fields = {
+        {"email", "admin@example.com"},
+        {"form_nonce", authentication_nonce}};
+    auto email = client.Post(
+        "/collection/authentication/email",
+        authentication_headers,
+        email_fields);
+    ASSERT_NE(email, nullptr);
+    ASSERT_EQ(email->status, 303) << email->body;
+    const std::string confirmation_path = urlRequestPath(
+        readFile(config.email.link_file));
+    ASSERT_FALSE(confirmation_path.empty());
+    auto limited_form = client.Get("/collection/authentication");
+    ASSERT_NE(limited_form, nullptr);
+    const httplib::Headers limited_headers = {{
+        "Cookie",
+        responseCookie(*limited_form, "card_collection_auth_form")}};
+    const httplib::Params limited_fields = {
+        {"email", "ADMIN@example.com"},
+        {"form_nonce", hiddenValue(limited_form->body, "form_nonce")}};
+    auto limited = client.Post(
+        "/collection/authentication/email",
+        limited_headers,
+        limited_fields);
+    ASSERT_NE(limited, nullptr);
+    EXPECT_EQ(limited->status, 429);
+    EXPECT_FALSE(limited->get_header_value("Retry-After").empty());
+    auto confirmation = client.Get(confirmation_path);
+    ASSERT_NE(confirmation, nullptr);
+    ASSERT_EQ(confirmation->status, 200) << confirmation->body;
+    EXPECT_EQ(
+        confirmation->get_header_value("Referrer-Policy"), "no-referrer");
+    const std::string confirmation_cookie = responseCookie(
+        *confirmation, "card_collection_confirm_form");
+    const std::string confirmation_nonce = hiddenValue(
+        confirmation->body, "form_nonce");
+    const httplib::Headers confirmation_headers = {
+        {"Cookie", confirmation_cookie}};
+    const httplib::Params confirmation_fields = {
+        {"form_nonce", confirmation_nonce}};
+    auto confirmed = client.Post(
+        confirmation_path,
+        confirmation_headers,
+        confirmation_fields);
+    ASSERT_NE(confirmed, nullptr);
+    ASSERT_EQ(confirmed->status, 303) << confirmed->body;
+    const std::string session_cookie = responseCookie(
+        *confirmed, "card_collection_session");
+    ASSERT_FALSE(session_cookie.empty());
+    EXPECT_NE(
+        confirmed->get_header_value("Set-Cookie").find("HttpOnly"),
+        std::string::npos);
+
+    const httplib::Headers session_headers = {{"Cookie", session_cookie}};
+    auto onboarding = client.Get(
+        "/collection/onboarding/username", session_headers);
+    ASSERT_NE(onboarding, nullptr);
+    ASSERT_EQ(onboarding->status, 200) << onboarding->body;
+    const std::string csrf_token = hiddenValue(
+        onboarding->body, "csrf_token");
+    ASSERT_FALSE(csrf_token.empty());
+    const httplib::Params onboarding_fields = {
+        {"username", "Administrator"}, {"csrf_token", csrf_token}};
+    auto onboarded = client.Post(
+        "/collection/onboarding/username",
+        session_headers,
+        onboarding_fields);
+    ASSERT_NE(onboarded, nullptr);
+    ASSERT_EQ(onboarded->status, 303) << onboarded->body;
+
+    auto card = client.Get("/collection/cards/test-1", session_headers);
     ASSERT_NE(card, nullptr);
-    EXPECT_EQ(card->status, 200);
+    EXPECT_EQ(card->status, 200) << card->body;
     EXPECT_NE(card->body.find("Card &lt;One&gt;"), std::string::npos);
-    auto edit = client.Get("/collection/cards/test-1/edit");
+    EXPECT_NE(card->body.find("Not currently"), std::string::npos);
+    auto create = client.Get("/collection/cards/new", session_headers);
+    ASSERT_NE(create, nullptr);
+    EXPECT_EQ(create->status, 200) << create->body;
+    auto edit = client.Get(
+        "/collection/cards/test-1/edit", session_headers);
     ASSERT_NE(edit, nullptr);
-    EXPECT_EQ(edit->status, 200);
+    EXPECT_EQ(edit->status, 200) << edit->body;
     EXPECT_NE(edit->body.find("value=\"20\""), std::string::npos);
-    auto card_delete = client.Get("/collection/cards/test-1/delete");
-    ASSERT_NE(card_delete, nullptr);
-    EXPECT_EQ(card_delete->status, 200);
-    auto series = client.Get("/collection/series");
+    auto series = client.Get(
+        "/collection/admin/series", session_headers);
     ASSERT_NE(series, nullptr);
-    EXPECT_EQ(series->status, 200);
+    EXPECT_EQ(series->status, 200) << series->body;
     EXPECT_NE(series->body.find("Core &lt;Set&gt;"), std::string::npos);
-    auto series_new = client.Get("/collection/series/new");
-    ASSERT_NE(series_new, nullptr);
-    EXPECT_EQ(series_new->status, 200);
-    auto series_edit = client.Get(
-        "/collection/series/" + std::to_string(*series_id) + "/edit");
-    ASSERT_NE(series_edit, nullptr);
-    EXPECT_EQ(series_edit->status, 200);
-    auto series_delete = client.Get(
-        "/collection/series/" + std::to_string(*series_id) + "/delete");
-    ASSERT_NE(series_delete, nullptr);
-    EXPECT_EQ(series_delete->status, 200);
     auto stylesheet = client.Get("/collection/static/css/styles.css");
     ASSERT_NE(stylesheet, nullptr);
     EXPECT_EQ(stylesheet->status, 200);
@@ -181,13 +310,15 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     EXPECT_EQ(traversal->status, 404);
 
     const httplib::UploadFormDataItems create_fields = {
+        {"csrf_token", csrf_token, "", ""},
         {"game", "", "", ""},
         {"name", "Uploaded card", "", ""},
         {"rarity", "3", "", ""},
         {"source_mode", "files", "", ""},
         {"front", upload_bytes, "card.png", "image/png"},
     };
-    auto created = client.Post("/collection/cards", create_fields);
+    auto created = client.Post(
+        "/collection/cards", session_headers, create_fields);
     ASSERT_NE(created, nullptr);
     ASSERT_EQ(created->status, 303) << created->body;
     const std::string location = created->get_header_value("Location");
@@ -200,6 +331,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         config.card_storage_root / "published" / public_id));
 
     const httplib::UploadFormDataItems edit_fields = {
+        {"csrf_token", csrf_token, "", ""},
         {"name", "Edited upload", "", ""},
         {"rarity", "4", "", ""},
         {"revision", "1", "", ""},
@@ -207,41 +339,174 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"front_action", "keep", "", ""},
         {"foil_action", "keep", "", ""},
     };
-    auto edited = client.Post(created_path, edit_fields);
+    auto edited = client.Post(
+        created_path + "/edit", session_headers, edit_fields);
     ASSERT_NE(edited, nullptr);
     EXPECT_EQ(edited->status, 303) << edited->body;
-    auto edited_page = client.Get(created_path);
+    auto edited_page = client.Get(created_path, session_headers);
     ASSERT_NE(edited_page, nullptr);
     EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
 
-    auto deleted = client.Post(created_path + "/delete", "", "text/plain");
+    const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
+    auto deleted = client.Post(
+        created_path + "/delete",
+        session_headers,
+        csrf_fields);
     ASSERT_NE(deleted, nullptr);
     EXPECT_EQ(deleted->status, 303) << deleted->body;
     EXPECT_FALSE(std::filesystem::exists(
         config.card_storage_root / "published" / public_id));
 
     httplib::Params create_series_fields = {
+        {"csrf_token", csrf_token},
         {"game", "test"},
         {"name", "Uploaded series"},
         {"description", "Description"},
     };
     auto created_series = client.Post(
-        "/collection/series", create_series_fields);
+        "/collection/admin/series", session_headers, create_series_fields);
     ASSERT_NE(created_series, nullptr);
     EXPECT_EQ(created_series->status, 303) << created_series->body;
     httplib::Params edit_series_fields = {
+        {"csrf_token", csrf_token},
         {"name", "Edited series"},
         {"description", "Changed"},
     };
     auto edited_series = client.Post(
-        "/collection/series/2", edit_series_fields);
+        "/collection/admin/series/2", session_headers, edit_series_fields);
     ASSERT_NE(edited_series, nullptr);
     EXPECT_EQ(edited_series->status, 303) << edited_series->body;
     auto deleted_series = client.Post(
-        "/collection/series/2/delete", "", "text/plain");
+        "/collection/admin/series/2/delete",
+        session_headers,
+        csrf_fields);
     ASSERT_NE(deleted_series, nullptr);
     EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;
 
+    auto player_authentication = client.Get("/collection/authentication");
+    ASSERT_NE(player_authentication, nullptr);
+    const std::string player_auth_cookie = responseCookie(
+        *player_authentication, "card_collection_auth_form");
+    const std::string player_auth_nonce = hiddenValue(
+        player_authentication->body, "form_nonce");
+    const httplib::Headers player_auth_headers = {
+        {"Cookie", player_auth_cookie}};
+    const httplib::Params player_email_fields = {
+        {"email", "player@example.com"},
+        {"form_nonce", player_auth_nonce}};
+    auto player_email = client.Post(
+        "/collection/authentication/email",
+        player_auth_headers,
+        player_email_fields);
+    ASSERT_NE(player_email, nullptr);
+    ASSERT_EQ(player_email->status, 303) << player_email->body;
+    const std::string player_confirmation_path = urlRequestPath(
+        readFile(config.email.link_file));
+    auto player_confirmation = client.Get(player_confirmation_path);
+    ASSERT_NE(player_confirmation, nullptr);
+    const std::string player_confirmation_cookie = responseCookie(
+        *player_confirmation, "card_collection_confirm_form");
+    const httplib::Headers player_confirmation_headers = {
+        {"Cookie", player_confirmation_cookie}};
+    const httplib::Params player_confirmation_fields = {
+        {"form_nonce", hiddenValue(
+            player_confirmation->body, "form_nonce")}};
+    auto player_confirmed = client.Post(
+        player_confirmation_path,
+        player_confirmation_headers,
+        player_confirmation_fields);
+    ASSERT_NE(player_confirmed, nullptr);
+    ASSERT_EQ(player_confirmed->status, 303) << player_confirmed->body;
+    const std::string player_session_cookie = responseCookie(
+        *player_confirmed, "card_collection_session");
+    const httplib::Headers player_headers = {
+        {"Cookie", player_session_cookie}};
+    auto player_onboarding = client.Get(
+        "/collection/onboarding/username", player_headers);
+    ASSERT_NE(player_onboarding, nullptr);
+    const std::string player_csrf = hiddenValue(
+        player_onboarding->body, "csrf_token");
+    const httplib::Params player_onboarding_fields = {
+        {"username", "Player"}, {"csrf_token", player_csrf}};
+    auto player_onboarded = client.Post(
+        "/collection/onboarding/username",
+        player_headers,
+        player_onboarding_fields);
+    ASSERT_NE(player_onboarded, nullptr);
+    ASSERT_EQ(player_onboarded->status, 303) << player_onboarded->body;
+    auto player_card_new = client.Get(
+        "/collection/cards/new", player_headers);
+    ASSERT_NE(player_card_new, nullptr);
+    EXPECT_EQ(player_card_new->status, 403);
+    auto player_admin = client.Get(
+        "/collection/admin/users", player_headers);
+    ASSERT_NE(player_admin, nullptr);
+    EXPECT_EQ(player_admin->status, 403);
+
+    auto promoted = client.Post(
+        "/collection/admin/users/2/promote",
+        session_headers,
+        csrf_fields);
+    ASSERT_NE(promoted, nullptr);
+    ASSERT_EQ(promoted->status, 303) << promoted->body;
+    auto creator_new = client.Get("/collection/cards/new", player_headers);
+    ASSERT_NE(creator_new, nullptr);
+    ASSERT_EQ(creator_new->status, 200) << creator_new->body;
+    EXPECT_EQ(creator_new->body.find("name=\"rarity\""), std::string::npos);
+    const httplib::UploadFormDataItems creator_card_fields = {
+        {"csrf_token", player_csrf, "", ""},
+        {"game", "", "", ""},
+        {"name", "Creator draft", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front", upload_bytes, "card.png", "image/png"},
+    };
+    auto creator_card = client.Post(
+        "/collection/cards", player_headers, creator_card_fields);
+    ASSERT_NE(creator_card, nullptr);
+    ASSERT_EQ(creator_card->status, 303) << creator_card->body;
+    const std::string creator_card_path = urlRequestPath(
+        creator_card->get_header_value("Location"));
+    auto creator_cards = client.Get(
+        "/collection/creator/cards", player_headers);
+    ASSERT_NE(creator_cards, nullptr);
+    EXPECT_EQ(creator_cards->status, 200) << creator_cards->body;
+    EXPECT_NE(creator_cards->body.find("Creator draft"), std::string::npos);
+    const httplib::UploadFormDataItems pool_edit_fields = {
+        {"csrf_token", csrf_token, "", ""},
+        {"name", "Creator draft", "", ""},
+        {"rarity", "2", "", ""},
+        {"revision", "1", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front_action", "keep", "", ""},
+        {"foil_action", "keep", "", ""},
+    };
+    auto pooled = client.Post(
+        creator_card_path + "/edit", session_headers, pool_edit_fields);
+    ASSERT_NE(pooled, nullptr);
+    ASSERT_EQ(pooled->status, 303) << pooled->body;
+    const httplib::Params player_pull_fields = {
+        {"csrf_token", player_csrf}};
+    auto player_pull = client.Post(
+        "/collection/collection/pull", player_headers, player_pull_fields);
+    ASSERT_NE(player_pull, nullptr);
+    ASSERT_EQ(player_pull->status, 303) << player_pull->body;
+    EXPECT_EQ(
+        urlRequestPath(player_pull->get_header_value("Location")),
+        creator_card_path);
+    auto owned_card = client.Get(creator_card_path, player_headers);
+    ASSERT_NE(owned_card, nullptr);
+    EXPECT_EQ(owned_card->status, 200) << owned_card->body;
+    EXPECT_NE(owned_card->body.find("100%"), std::string::npos);
+    const httplib::Params player_logout_fields = {
+        {"csrf_token", player_csrf}};
+    auto logged_out = client.Post(
+        "/collection/logout", player_headers, player_logout_fields);
+    ASSERT_NE(logged_out, nullptr);
+    EXPECT_EQ(logged_out->status, 303) << logged_out->body;
+    auto after_logout = client.Get("/collection/collection", player_headers);
+    ASSERT_NE(after_logout, nullptr);
+    EXPECT_EQ(after_logout->status, 303);
+
     app.stop();
     app.wait();
 }
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 6f6be51..c572a3d 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -79,6 +79,47 @@ Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
     };
 }
 
+class AuthenticatedDataSource : public DataSourceFake
+{
+public:
+    using DataSourceFake::DataSourceFake;
+
+    /// Return one deterministic onboarded administrator session.
+    mw::E<std::optional<SessionContext>> getSession(
+        [[maybe_unused]] const TokenHash& token_hash,
+        [[maybe_unused]] std::int64_t now) const override
+    {
+        return SessionContext{
+            1,
+            {1, "admin@example.com", "admin@example.com", "Admin",
+             UserRole::ADMINISTRATOR, 1, 0, 0},
+            std::string(64, 'c'),
+            9999999999};
+    }
+
+    /// Treat configured cards as owned for focused rendering tests.
+    mw::E<bool> userOwnsCard(
+        [[maybe_unused]] std::int64_t user_id,
+        [[maybe_unused]] std::int64_t card_id) const override
+    {
+        return true;
+    }
+
+    /// Return configured cards as the focused test pool.
+    mw::E<std::vector<Card>> getPoolCards() const override
+    {
+        return getCards();
+    }
+};
+
+App::Request authenticatedRequest()
+{
+    App::Request request;
+    request.set_header(
+        "Cookie", "card_collection_session=" + std::string(64, 'a'));
+    return request;
+}
+
 } // namespace
 
 /// Verify named routes respect a nested application base path.
@@ -92,7 +133,7 @@ TEST(AppTest, BuildsNamedUrls)
 
     EXPECT_EQ(
         app.urlFor("card-index"),
-        "https://example.test/collection/");
+        "https://example.test/collection/admin/cards");
     EXPECT_EQ(
         app.urlFor("card-new"),
         "https://example.test/collection/cards/new");
@@ -101,7 +142,7 @@ TEST(AppTest, BuildsNamedUrls)
         "https://example.test/collection/cards/PKM%2f2");
     EXPECT_EQ(
         app.urlFor("series-edit", {"42"}),
-        "https://example.test/collection/series/42/edit");
+        "https://example.test/collection/admin/series/42/edit");
 }
 
 /// Verify static relative paths and ordered query values are encoded.
@@ -140,7 +181,7 @@ TEST(AppTest, RejectsInvalidRoutes)
 /// Verify the index renders escaped fake records in natural ID order.
 TEST(AppTest, RendersCardIndex)
 {
-    auto data_source = std::make_unique<DataSourceFake>(
+    auto data_source = std::make_unique<AuthenticatedDataSource>(
         std::vector<Card>{
             makeCard(10, 10, "Tenth card"),
             makeCard(2, 2, "<script>Second card</script>"),
@@ -150,7 +191,7 @@ TEST(AppTest, RendersCardIndex)
         std::move(data_source),
         emptyGames(),
         deterministicRandom());
-    App::Request request;
+    App::Request request = authenticatedRequest();
     App::Response response;
 
     app.handleCardIndex(request, response);
@@ -176,10 +217,10 @@ TEST(AppTest, RendersCardCreationForm)
 {
     App app(
         makeConfig("https://example.test/collection/"),
-        emptyDataSource(),
+        std::make_unique<AuthenticatedDataSource>(),
         emptyGames(),
         deterministicRandom());
-    App::Request request;
+    App::Request request = authenticatedRequest();
     App::Response response;
 
     app.handleCardNew(request, response);
@@ -206,7 +247,7 @@ TEST(AppTest, RendersCardEditForm)
     card.identity.game_short_name = std::nullopt;
     card.short_description = "A quiet night.";
     card.rarity = 4;
-    auto data_source = std::make_unique<DataSourceFake>(
+    auto data_source = std::make_unique<AuthenticatedDataSource>(
         std::vector<Card>{card});
     App app(
         makeConfig("https://example.test/collection/"),
@@ -215,7 +256,7 @@ TEST(AppTest, RendersCardEditForm)
         deterministicRandom());
     auto public_id = formatPublicId(card.identity);
     ASSERT_TRUE(public_id);
-    App::Request request;
+    App::Request request = authenticatedRequest();
     request.path_params.emplace("id", *public_id);
     App::Response response;
 
@@ -243,7 +284,7 @@ TEST(AppTest, RendersCardView)
     card.short_description = "A quiet <night>.";
     card.long_description = "First line\nSecond line";
     card.rarity = 4;
-    auto data_source = std::make_unique<DataSourceFake>(
+    auto data_source = std::make_unique<AuthenticatedDataSource>(
         std::vector<Card>{card},
         std::vector<Series>{
             {7, "test", "Night Signals", "A series description"},
@@ -256,7 +297,7 @@ TEST(AppTest, RendersCardView)
         std::move(data_source),
         testGames(),
         deterministicRandom());
-    App::Request request;
+    App::Request request = authenticatedRequest();
     request.path_params.emplace("id", "test-2");
     App::Response response;
 
@@ -280,10 +321,10 @@ TEST(AppTest, RejectsUnknownCardView)
 {
     App app(
         makeConfig("https://example.test/"),
-        emptyDataSource(),
+        std::make_unique<AuthenticatedDataSource>(),
         emptyGames(),
         deterministicRandom());
-    App::Request request;
+    App::Request request = authenticatedRequest();
     request.path_params.emplace("id", "PKM-2");
     App::Response response;
 
diff --git a/tests/authentication_test.cpp b/tests/authentication_test.cpp
new file mode 100644
index 0000000..46abf2d
--- /dev/null
+++ b/tests/authentication_test.cpp
@@ -0,0 +1,194 @@
+#include <chrono>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/crypto_mock.hpp>
+
+#include "authentication.h"
+#include "game_registry.h"
+#include "startup.h"
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+    TemporaryDatabase()
+        : path_(
+              std::filesystem::path(testing::TempDir()) /
+              ("authentication_service_" + std::to_string(
+                  std::chrono::steady_clock::now()
+                      .time_since_epoch().count()) + ".sqlite3"))
+    {}
+
+    ~TemporaryDatabase()
+    {
+        std::error_code error;
+        std::filesystem::remove(path_, error);
+        std::filesystem::remove(path_.string() + "-shm", error);
+        std::filesystem::remove(path_.string() + "-wal", error);
+    }
+
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+class ClockMock final : public ClockInterface
+{
+public:
+    std::chrono::system_clock::time_point now() const override
+    {
+        return current;
+    }
+
+    std::chrono::system_clock::time_point current =
+        std::chrono::system_clock::time_point(std::chrono::seconds(100));
+};
+
+class EmailSenderMock final : public EmailSenderInterface
+{
+public:
+    mw::E<void> send(const AuthenticationEmail& email) override
+    {
+        last_email = email;
+        if(fail)
+        {
+            return std::unexpected(mw::runtimeError("Sender failed"));
+        }
+        return {};
+    }
+
+    bool usesGlobalQuota() const override
+    {
+        return global_quota;
+    }
+
+    std::optional<AuthenticationEmail> last_email;
+    bool fail = false;
+    bool global_quota = true;
+};
+
+std::string finalPathSegment(const mw::URL& url)
+{
+    const std::string path = url.path();
+    return path.substr(path.find_last_of('/') + 1);
+}
+
+std::vector<std::byte> bytes(unsigned char value)
+{
+    return std::vector<std::byte>(32, static_cast<std::byte>(value));
+}
+
+std::string hexToken(char low_nibble)
+{
+    std::string result;
+    result.reserve(64);
+    for(int index = 0; index < 32; ++index)
+    {
+        result.push_back('0');
+        result.push_back(low_nibble);
+    }
+    return result;
+}
+
+} // namespace
+
+TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto base_url = mw::URL::fromStr("http://127.0.0.1/app/");
+    ASSERT_TRUE(base_url);
+    ClockMock clock;
+    EmailSenderMock sender;
+    mw::CryptoMock crypto;
+    EXPECT_CALL(crypto, randomBytes(32))
+        .WillOnce(testing::Return(bytes(1)))
+        .WillOnce(testing::Return(bytes(2)))
+        .WillOnce(testing::Return(bytes(3)));
+    AuthenticationService authentication(
+        **data_source, sender, clock, crypto, std::move(*base_url), 10);
+
+    auto requested = authentication.requestEmail("Player@Example.com");
+    ASSERT_TRUE(requested) << requested.error().msg();
+    ASSERT_TRUE(sender.last_email);
+    EXPECT_EQ(sender.last_email->recipient, "Player@Example.com");
+    const std::string challenge_token = finalPathSegment(
+        sender.last_email->confirmation_url);
+    ASSERT_EQ(challenge_token, hexToken('1'));
+    auto opened = authentication.validate(challenge_token);
+    ASSERT_TRUE(opened);
+    ASSERT_TRUE(*opened);
+    auto opened_again = authentication.validate(challenge_token);
+    ASSERT_TRUE(opened_again);
+    ASSERT_TRUE(*opened_again);
+
+    auto established = authentication.confirm(challenge_token);
+    ASSERT_TRUE(established) << established.error().msg();
+    EXPECT_EQ(established->user.email, "Player@Example.com");
+    EXPECT_EQ(established->user.role, UserRole::PLAYER);
+    EXPECT_FALSE(established->user.username);
+    EXPECT_EQ(established->token, hexToken('2'));
+    EXPECT_EQ(
+        established->csrf_token,
+        hexToken('3'));
+    opened = authentication.validate(challenge_token);
+    ASSERT_TRUE(opened);
+    EXPECT_FALSE(*opened);
+
+    clock.current = std::chrono::system_clock::time_point(
+        std::chrono::seconds(200));
+    auto session = authentication.session(established->token);
+    ASSERT_TRUE(session);
+    ASSERT_TRUE(*session);
+    EXPECT_EQ((**session).expires_at, established->expires_at);
+    ASSERT_TRUE(authentication.logout(established->token));
+    session = authentication.session(established->token);
+    ASSERT_TRUE(session);
+    EXPECT_FALSE(*session);
+}
+
+TEST(AuthenticationServiceTest, RateLimitsAndInvalidatesSenderFailure)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto base_url = mw::URL::fromStr("http://127.0.0.1/");
+    ASSERT_TRUE(base_url);
+    ClockMock clock;
+    EmailSenderMock sender;
+    sender.global_quota = false;
+    mw::CryptoMock crypto;
+    EXPECT_CALL(crypto, randomBytes(32))
+        .WillOnce(testing::Return(bytes(4)))
+        .WillOnce(testing::Return(bytes(5)));
+    AuthenticationService authentication(
+        **data_source, sender, clock, crypto, std::move(*base_url), 10);
+
+    ASSERT_TRUE(authentication.requestEmail("person@example.com"));
+    EXPECT_FALSE(authentication.requestEmail("PERSON@example.com"));
+    clock.current = std::chrono::system_clock::time_point(
+        std::chrono::seconds(161));
+    sender.fail = true;
+    EXPECT_FALSE(authentication.requestEmail("other@example.com"));
+    ASSERT_TRUE(sender.last_email);
+    auto challenge = authentication.validate(
+        finalPathSegment(sender.last_email->confirmation_url));
+    ASSERT_TRUE(challenge);
+    EXPECT_FALSE(*challenge);
+}
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index bb65c49..642a8e2 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -2,6 +2,7 @@
 #include "data_sqlite.h"
 #include "game_registry.h"
 #include "multipart_reader.h"
+#include "series_service.h"
 #include "startup.h"
 #include "test_game.h"
 
@@ -84,6 +85,34 @@ std::string readFile(const std::filesystem::path& path)
         std::istreambuf_iterator<char>());
 }
 
+mw::E<std::unique_ptr<DataSourceInterface>> prepareTestDataSource(
+    const std::filesystem::path& database,
+    const GameRegistry& games)
+{
+    auto data_source = prepareDataSource(database, games);
+    if(!data_source)
+    {
+        return std::unexpected(std::move(data_source.error()));
+    }
+    auto transaction = (*data_source)->beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto updated = (*transaction)->updateUsername(
+        1, "Administrator", "administrator");
+    if(!updated)
+    {
+        return std::unexpected(std::move(updated.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return std::move(*data_source);
+}
+
 } // namespace
 
 /// Verify card creation publishes normalized assets with its committed row.
@@ -93,7 +122,7 @@ TEST(CardServiceTest, CreatesLooseCard)
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
     GameRegistry games;
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareTestDataSource(database, games);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path staging =
@@ -107,7 +136,7 @@ TEST(CardServiceTest, CreatesLooseCard)
         random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
-    auto public_id = service.createLooseCard({
+    auto public_id = service.createLooseCard(1, {
         "Moon card",
         std::optional<std::string>("Short"),
         std::nullopt,
@@ -147,7 +176,7 @@ TEST(CardServiceTest, RejectsInvalidImage)
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
     GameRegistry games;
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareTestDataSource(database, games);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path staging =
@@ -163,7 +192,7 @@ TEST(CardServiceTest, RejectsInvalidImage)
         random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
-    auto public_id = service.createLooseCard({
+    auto public_id = service.createLooseCard(1, {
         "Bad card",
         std::nullopt,
         std::nullopt,
@@ -188,7 +217,7 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
     GameRegistry games;
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareTestDataSource(database, games);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path staging =
@@ -206,7 +235,7 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
         random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
-    auto public_id = service.createLooseCard({
+    auto public_id = service.createLooseCard(1, {
         "Opaque foil",
         std::nullopt,
         std::nullopt,
@@ -237,7 +266,7 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
     ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
     const GameDefinition* game = games.find("test");
     ASSERT_NE(game, nullptr);
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareTestDataSource(database, games);
     ASSERT_TRUE(data_source);
 
     auto series_transaction = (*data_source)->beginTransaction();
@@ -260,6 +289,7 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
     auto public_id = service.createGameCard(
+        1,
         {
             "Game card",
             std::nullopt,
@@ -293,6 +323,7 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
         temporary.path() / ".staging/edit";
     std::filesystem::create_directory(edit_staging);
     auto updated = service.updateGameCard(
+        1,
         {
             **stored,
             1,
@@ -330,7 +361,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
     GameRegistry games;
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareTestDataSource(database, games);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path create_staging =
@@ -344,7 +375,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
         random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
-    auto public_id = service.createLooseCard({
+    auto public_id = service.createLooseCard(1, {
         "Original",
         std::nullopt,
         std::nullopt,
@@ -363,7 +394,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
     const std::filesystem::path metadata_staging =
         temporary.path() / ".staging/metadata";
     std::filesystem::create_directory(metadata_staging);
-    auto metadata_update = service.updateLooseCard({
+    auto metadata_update = service.updateLooseCard(1, {
         original_card,
         1,
         "Edited metadata",
@@ -392,7 +423,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
     const std::filesystem::path stale_staging =
         temporary.path() / ".staging/stale";
     std::filesystem::create_directory(stale_staging);
-    auto stale_update = service.updateLooseCard({
+    auto stale_update = service.updateLooseCard(1, {
         original_card,
         1,
         "Stale edit",
@@ -418,7 +449,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
     const std::filesystem::path replacement_front =
         artwork_staging / "upload_front";
     writeJpeg(replacement_front);
-    auto artwork_update = service.updateLooseCard({
+    auto artwork_update = service.updateLooseCard(1, {
         cards->front(),
         2,
         "Edited artwork",
@@ -445,7 +476,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
         std::filesystem::exists(published / "front-art.avif"));
     EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
 
-    auto deleted = service.deleteCard(cards->front());
+    auto deleted = service.deleteCard(1, cards->front());
     ASSERT_TRUE(deleted) << deleted.error().msg();
     cards = (*data_source)->getCards();
     ASSERT_TRUE(cards);
@@ -460,7 +491,7 @@ TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
     GameRegistry games;
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareTestDataSource(database, games);
     ASSERT_TRUE(data_source);
     const std::filesystem::path staging =
         temporary.path() / ".staging/upload";
@@ -473,7 +504,7 @@ TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
         random,
         ImageProcessor(75, 256),
         assets);
-    auto public_id = service.createLooseCard({
+    auto public_id = service.createLooseCard(1, {
         "Recovery card",
         std::nullopt,
         std::nullopt,
@@ -559,3 +590,86 @@ TEST(MultipartReaderTest, StreamsExpectedFields)
     EXPECT_EQ(readFile(*upload->front), image_bytes);
     EXPECT_FALSE(upload->foil);
 }
+
+/// Verify creator authorship and rarity rules survive direct service calls.
+TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    GameRegistry games;
+    ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
+    auto data_source = prepareTestDataSource(database, games);
+    ASSERT_TRUE(data_source);
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    User creator = {
+        0,
+        "creator@example.com",
+        "creator@example.com",
+        std::nullopt,
+        UserRole::CREATOR,
+        1,
+        0,
+        0};
+    auto creator_id = (*transaction)->insertUser(creator);
+    ASSERT_TRUE(creator_id);
+    ASSERT_TRUE((*transaction)->updateUsername(
+        *creator_id, "Creator", "creator"));
+    ASSERT_TRUE((*transaction)->commit());
+
+    const std::filesystem::path staging =
+        temporary.path() / ".staging/creator";
+    const std::filesystem::path front = staging / "upload_front";
+    std::filesystem::create_directories(staging);
+    writePng(front);
+    NonSecretRandom random(5);
+    CardService service(
+        **data_source,
+        random,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    CreateLooseCardInput input = {
+        "Creator card",
+        std::nullopt,
+        std::nullopt,
+        9,
+        staging,
+        front,
+        std::nullopt,
+        std::nullopt,
+        false};
+    auto public_id = service.createLooseCard(*creator_id, std::move(input));
+    ASSERT_TRUE(public_id) << public_id.error().msg();
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    EXPECT_EQ(cards->front().creator_user_id, *creator_id);
+    EXPECT_EQ(cards->front().rarity, 0);
+    EXPECT_FALSE(service.deleteCard(*creator_id, cards->front()));
+
+    const std::filesystem::path tampered_staging =
+        temporary.path() / ".staging/tampered";
+    const std::filesystem::path tampered_front =
+        tampered_staging / "upload_front";
+    std::filesystem::create_directories(tampered_staging);
+    writePng(tampered_front);
+    EXPECT_FALSE(service.createLooseCard(*creator_id, {
+        "Tampered card",
+        std::nullopt,
+        std::nullopt,
+        1,
+        tampered_staging,
+        tampered_front,
+        std::nullopt,
+        std::nullopt,
+        true,
+    }));
+    SeriesService series_service(**data_source, games);
+    auto forbidden_series = series_service.create(
+        *creator_id, "test", "Creator series", "Not allowed");
+    ASSERT_FALSE(forbidden_series);
+    const auto* http_error = forbidden_series.error().as<mw::HTTPError>();
+    ASSERT_NE(http_error, nullptr);
+    EXPECT_EQ(http_error->code, 403);
+}
diff --git a/tests/config_test.cpp b/tests/config_test.cpp
index 2dec367..2f90762 100644
--- a/tests/config_test.cpp
+++ b/tests/config_test.cpp
@@ -57,6 +57,13 @@ void writeConfig(
         << "card_storage_root = \"var/cards\"\n"
         << "avif_quality = 75\n"
         << "thumbnail_long_side = 256\n"
+        << "administrator_email = \"Admin@Example.com\"\n"
+        << "maximum_accumulated_pulls = 3\n"
+        << "[email]\n"
+        << "transport = \"file\"\n"
+        << "link_file = \""
+        << (path.parent_path() / "auth-link.txt").string()
+        << "\"\n"
         << extra;
 }
 
@@ -84,6 +91,8 @@ TEST(ConfigTest, LoadsTcpConfiguration)
     EXPECT_EQ(address.address, "127.0.0.1");
     EXPECT_EQ(address.port, 8080);
     EXPECT_EQ(config->static_root, temporary.path() / "static");
+    EXPECT_EQ(config->administrator_email_key, "admin@example.com");
+    EXPECT_EQ(config->maximum_accumulated_pulls, 3);
     EXPECT_TRUE(std::filesystem::is_directory(
         temporary.path() / "var/cards/published"));
 }
diff --git a/tests/data_mock.h b/tests/data_mock.h
index b658f16..c38f2f9 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -35,6 +35,14 @@ public:
         (std::uint32_t number),
         (override));
 
+    /// Mock a user read performed under the transaction lock.
+    MOCK_METHOD((mw::E<std::optional<User>>), getUserForUpdate,
+                (std::int64_t user_id), (override));
+
+    /// Mock a normalized-email user read under the transaction lock.
+    MOCK_METHOD((mw::E<std::optional<User>>), getUserByEmailKeyForUpdate,
+                (const std::string& email_key), (override));
+
     /// Mock a card read performed under the transaction lock.
     MOCK_METHOD(
         (mw::E<std::optional<Card>>),
@@ -42,6 +50,79 @@ public:
         (std::int64_t card_id),
         (override));
 
+    /// Mock ownership under the transaction lock.
+    MOCK_METHOD((mw::E<bool>), userOwnsCardForUpdate,
+                (std::int64_t user_id, std::int64_t card_id), (override));
+
+    /// Mock a positive-rarity pool read under the transaction lock.
+    MOCK_METHOD((mw::E<std::vector<Card>>), getPoolCardsForUpdate,
+                (), (override));
+
+    /// Mock insertion of a newly confirmed user.
+    MOCK_METHOD((mw::E<std::int64_t>), insertUser,
+                (const User& user), (override));
+
+    /// Mock replacement of a normalized username pair.
+    MOCK_METHOD((mw::E<bool>), updateUsername,
+                (std::int64_t user_id, const std::string& username,
+                 const std::string& username_key), (override));
+
+    /// Mock permanent player promotion.
+    MOCK_METHOD((mw::E<bool>), promoteUser,
+                (std::int64_t user_id), (override));
+
+    /// Mock atomic authentication-email capacity reservation.
+    MOCK_METHOD((mw::E<AuthenticationReservation>),
+                reserveAuthenticationEmail,
+                (const std::string& email_key, std::int64_t now,
+                 bool use_global_quota, std::int64_t utc_day,
+                 std::uint32_t daily_limit), (override));
+
+    /// Mock insertion of a pending authentication challenge.
+    MOCK_METHOD((mw::E<std::int64_t>), insertAuthenticationChallenge,
+                (const std::string& email, const std::string& email_key,
+                 const TokenHash& token_hash, std::int64_t created_at,
+                 std::int64_t expires_at), (override));
+
+    /// Mock activation of a delivered authentication challenge.
+    MOCK_METHOD((mw::E<bool>), markAuthenticationChallengeDelivered,
+                (std::int64_t challenge_id, std::int64_t delivered_at),
+                (override));
+
+    /// Mock removal of a failed authentication challenge.
+    MOCK_METHOD((mw::E<void>), deleteAuthenticationChallenge,
+                (std::int64_t challenge_id), (override));
+
+    /// Mock conditional authentication challenge consumption.
+    MOCK_METHOD((mw::E<std::optional<AuthenticationChallenge>>),
+                consumeAuthenticationChallenge,
+                (const TokenHash& token_hash, std::int64_t now), (override));
+
+    /// Mock invalidation of an email identity's other challenges.
+    MOCK_METHOD((mw::E<void>), invalidateAuthenticationChallenges,
+                (const std::string& email_key,
+                 std::int64_t except_challenge_id, std::int64_t now),
+                (override));
+
+    /// Mock insertion of a non-sliding session.
+    MOCK_METHOD((mw::E<std::int64_t>), insertSession,
+                (std::int64_t user_id, const TokenHash& token_hash,
+                 const std::string& csrf_token, std::int64_t created_at,
+                 std::int64_t expires_at), (override));
+
+    /// Mock deletion of a session digest.
+    MOCK_METHOD((mw::E<void>), deleteSession,
+                (const TokenHash& token_hash), (override));
+
+    /// Mock persistence of lazily refreshed pull state.
+    MOCK_METHOD((mw::E<void>), updatePullState,
+                (std::int64_t user_id, std::uint32_t stored_pulls,
+                 std::int64_t refresh_day), (override));
+
+    /// Mock insertion or increment of one collection holding.
+    MOCK_METHOD((mw::E<std::int64_t>), incrementHolding,
+                (std::int64_t user_id, std::int64_t card_id), (override));
+
     /// Mock insertion of a card and its dependent rows.
     MOCK_METHOD(
         (mw::E<std::int64_t>),
@@ -126,6 +207,14 @@ public:
         (),
         (const, override));
 
+    /// Mock retrieval of one creator's authored cards.
+    MOCK_METHOD((mw::E<std::vector<Card>>), getCardsByCreator,
+                (std::int64_t creator_user_id), (const, override));
+
+    /// Mock retrieval of the current positive-rarity pool.
+    MOCK_METHOD((mw::E<std::vector<Card>>), getPoolCards,
+                (), (const, override));
+
     /// Mock retrieval of a card by identity.
     MOCK_METHOD(
         (mw::E<std::optional<Card>>),
@@ -133,6 +222,48 @@ public:
         (const CardIdentity& identity),
         (const, override));
 
+    /// Mock retrieval of a user by internal identity.
+    MOCK_METHOD((mw::E<std::optional<User>>), getUser,
+                (std::int64_t user_id), (const, override));
+
+    /// Mock retrieval of a user by normalized immutable email.
+    MOCK_METHOD((mw::E<std::optional<User>>), getUserByEmailKey,
+                (const std::string& email_key), (const, override));
+
+    /// Mock retrieval of all administrator-visible users.
+    MOCK_METHOD((mw::E<std::vector<User>>), getUsers,
+                (), (const, override));
+
+    /// Mock retrieval of a valid joined session.
+    MOCK_METHOD((mw::E<std::optional<SessionContext>>), getSession,
+                (const TokenHash& token_hash, std::int64_t now),
+                (const, override));
+
+    /// Mock read-only authentication challenge validation.
+    MOCK_METHOD((mw::E<std::optional<AuthenticationChallenge>>),
+                getAuthenticationChallenge,
+                (const TokenHash& token_hash, std::int64_t now),
+                (const, override));
+
+    /// Mock retrieval of one user's distinct collection.
+    MOCK_METHOD((mw::E<std::vector<CollectionEntry>>), getCollection,
+                (std::int64_t user_id), (const, override));
+
+    /// Mock a persisted card-ownership query.
+    MOCK_METHOD((mw::E<bool>), userOwnsCard,
+                (std::int64_t user_id, std::int64_t card_id),
+                (const, override));
+
+    /// Mock immutable administrator reconciliation.
+    MOCK_METHOD((mw::E<User>), reconcileAdministrator,
+                (const std::string& email, const std::string& email_key,
+                 std::int64_t created_at, std::int64_t pull_refresh_day),
+                (override));
+
+    /// Mock best-effort authentication cleanup.
+    MOCK_METHOD((mw::E<void>), cleanupAuthentication,
+                (std::int64_t now), (override));
+
     /// Mock retrieval of game-specific card display fields.
     MOCK_METHOD(
         (mw::E<std::vector<DisplayField>>),
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 5fd3eb8..f177ddd 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -59,6 +59,7 @@ Card makeLooseCard(std::uint32_t number, std::string name)
         std::nullopt,
         "avif",
         1,
+        1,
     };
 }
 
@@ -112,6 +113,7 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
     ASSERT_TRUE((*connection)->execute(
         "CREATE TABLE cards ("
         "id INTEGER PRIMARY KEY, "
+        "creator_user_id INTEGER NOT NULL, "
         "game_short_name TEXT, "
         "card_number INTEGER NOT NULL, "
         "name TEXT NOT NULL, "
@@ -123,10 +125,10 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
         "thumbnail_extension TEXT NOT NULL, "
         "revision INTEGER NOT NULL);"));
     ASSERT_TRUE((*connection)->execute(
-        "INSERT INTO cards VALUES (2, 'pkm', 7, 'Moon card', "
+        "INSERT INTO cards VALUES (2, 1, 'pkm', 7, 'Moon card', "
         "'Short', 'Long', 4, 'avif', 'webp', 'avif', 3);"));
     ASSERT_TRUE((*connection)->execute(
-        "INSERT INTO cards VALUES (1, NULL, 35, 'Loose card', "
+        "INSERT INTO cards VALUES (1, 1, NULL, 35, 'Loose card', "
         "NULL, NULL, 0, 'jpg', NULL, 'webp', 1);"));
     connection->reset();
 
@@ -147,6 +149,7 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
     EXPECT_EQ((*cards)[0].foil_extension, std::nullopt);
     EXPECT_EQ((*cards)[0].thumbnail_extension, "webp");
     EXPECT_EQ((*cards)[0].revision, 1);
+    EXPECT_EQ((*cards)[0].creator_user_id, 1);
 
     EXPECT_EQ((*cards)[1].id, 2);
     EXPECT_EQ((*cards)[1].identity.game_short_name, "pkm");
@@ -175,12 +178,13 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
     ASSERT_TRUE(connection);
     ASSERT_TRUE((*connection)->execute(
         "CREATE TABLE cards ("
-        "id INTEGER, game_short_name TEXT, card_number INTEGER, "
+        "id INTEGER, creator_user_id INTEGER, game_short_name TEXT, "
+        "card_number INTEGER, "
         "name TEXT, short_description TEXT, long_description TEXT, "
         "rarity INTEGER, front_extension TEXT, foil_extension TEXT, "
         "thumbnail_extension TEXT, revision INTEGER);"));
     ASSERT_TRUE((*connection)->execute(
-        "INSERT INTO cards VALUES (1, NULL, -1, 'Broken', NULL, NULL, "
+        "INSERT INTO cards VALUES (1, 1, NULL, -1, 'Broken', NULL, NULL, "
         "0, 'jpg', NULL, 'jpg', 1);"));
     connection->reset();
 
@@ -334,6 +338,7 @@ TEST(DataSourceSQLiteTest, PersistsCompiledGameCards)
         std::nullopt,
         "avif",
         1,
+        1,
     };
     auto card_id = (*transaction)->insertCard(
         card, game, metadata->get(), {*series_id});
@@ -445,3 +450,191 @@ TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
     EXPECT_FALSE((*second_transaction)->insertCard(
         makeLooseCard(8, "Duplicate"), nullptr, nullptr, {}));
 }
+
+/// Verify administrator metadata is immutable and exactly one row is kept.
+TEST(DataSourceSQLiteTest, ReconcilesImmutableAdministrator)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(
+        database.path(), games, "Admin@Example.com", "admin@example.com",
+        100, 0);
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+
+    auto administrator = (*data_source)->getUserByEmailKey(
+        "admin@example.com");
+    ASSERT_TRUE(administrator);
+    ASSERT_TRUE(*administrator);
+    EXPECT_EQ((**administrator).email, "Admin@Example.com");
+    EXPECT_EQ((**administrator).role, UserRole::ADMINISTRATOR);
+    EXPECT_EQ((**administrator).stored_pulls, 1);
+
+    auto same = (*data_source)->reconcileAdministrator(
+        "admin@example.com", "admin@example.com", 200, 1);
+    ASSERT_TRUE(same);
+    EXPECT_EQ(same->id, (**administrator).id);
+    EXPECT_FALSE((*data_source)->reconcileAdministrator(
+        "other@example.com", "other@example.com", 200, 1));
+
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    User second_administrator = {
+        0,
+        "second@example.com",
+        "second@example.com",
+        std::nullopt,
+        UserRole::ADMINISTRATOR,
+        1,
+        0,
+        100};
+    EXPECT_FALSE((*transaction)->insertUser(second_administrator));
+}
+
+/// Verify challenges, sessions, limits, users, and holdings are transactional.
+TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    auto administrator = (*data_source)->getUserByEmailKey(
+        "admin@example.com");
+    ASSERT_TRUE(administrator);
+    ASSERT_TRUE(*administrator);
+
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    User player = {
+        0,
+        "Player@Example.com",
+        "player@example.com",
+        std::nullopt,
+        UserRole::PLAYER,
+        1,
+        0,
+        10};
+    auto player_id = (*transaction)->insertUser(player);
+    ASSERT_TRUE(player_id) << player_id.error().msg();
+    ASSERT_TRUE((*transaction)->commit());
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->updateUsername(
+        *player_id, "Player", "player"));
+    ASSERT_TRUE((*transaction)->promoteUser(*player_id));
+    auto reservation = (*transaction)->reserveAuthenticationEmail(
+        "player@example.com", 100, true, 0, 1);
+    ASSERT_TRUE(reservation) << reservation.error().msg();
+    EXPECT_EQ(
+        reservation->status,
+        AuthenticationReservationStatus::RESERVED);
+    ASSERT_TRUE((*transaction)->commit());
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    reservation = (*transaction)->reserveAuthenticationEmail(
+        "player@example.com", 130, true, 0, 1);
+    ASSERT_TRUE(reservation);
+    EXPECT_EQ(
+        reservation->status,
+        AuthenticationReservationStatus::EMAIL_LIMITED);
+    EXPECT_EQ(reservation->retry_after, 30);
+    ASSERT_TRUE((*transaction)->commit());
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    reservation = (*transaction)->reserveAuthenticationEmail(
+        "new@example.com", 161, true, 0, 1);
+    ASSERT_TRUE(reservation);
+    EXPECT_EQ(
+        reservation->status,
+        AuthenticationReservationStatus::GLOBAL_LIMITED);
+    ASSERT_TRUE((*transaction)->commit());
+
+    const TokenHash challenge_hash(32, 7);
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    reservation = (*transaction)->reserveAuthenticationEmail(
+        "new@example.com", 161, false, 0, 1);
+    ASSERT_TRUE(reservation);
+    EXPECT_EQ(
+        reservation->status,
+        AuthenticationReservationStatus::RESERVED);
+    auto challenge_id = (*transaction)->insertAuthenticationChallenge(
+        "new@example.com", "new@example.com", challenge_hash, 161, 761);
+    ASSERT_TRUE(challenge_id) << challenge_id.error().msg();
+    ASSERT_TRUE((*transaction)->commit());
+    auto challenge = (*data_source)->getAuthenticationChallenge(
+        challenge_hash, 200);
+    ASSERT_TRUE(challenge);
+    EXPECT_FALSE(*challenge);
+
+    const TokenHash session_hash(32, 9);
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->markAuthenticationChallengeDelivered(
+        *challenge_id, 170));
+    auto consumed = (*transaction)->consumeAuthenticationChallenge(
+        challenge_hash, 200);
+    ASSERT_TRUE(consumed);
+    ASSERT_TRUE(*consumed);
+    auto session_id = (*transaction)->insertSession(
+        *player_id, session_hash, std::string(64, 'c'), 200, 400);
+    ASSERT_TRUE(session_id) << session_id.error().msg();
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto session = (*data_source)->getSession(session_hash, 300);
+    ASSERT_TRUE(session);
+    ASSERT_TRUE(*session);
+    EXPECT_EQ((**session).user.id, *player_id);
+    EXPECT_EQ((**session).user.role, UserRole::CREATOR);
+    EXPECT_EQ((**session).csrf_token, std::string(64, 'c'));
+    auto expired = (*data_source)->getSession(session_hash, 400);
+    ASSERT_TRUE(expired);
+    EXPECT_FALSE(*expired);
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    Card card = makeLooseCard(123, "Collectible");
+    card.creator_user_id = (**administrator).id;
+    auto card_id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+    ASSERT_TRUE(card_id) << card_id.error().msg();
+    auto quantity = (*transaction)->incrementHolding(*player_id, *card_id);
+    ASSERT_TRUE(quantity);
+    EXPECT_EQ(*quantity, 1);
+    quantity = (*transaction)->incrementHolding(*player_id, *card_id);
+    ASSERT_TRUE(quantity);
+    EXPECT_EQ(*quantity, 2);
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto collection = (*data_source)->getCollection(*player_id);
+    ASSERT_TRUE(collection);
+    ASSERT_EQ(collection->size(), 1);
+    EXPECT_EQ(collection->front().quantity, 2);
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->deleteCard(*card_id));
+    ASSERT_TRUE((*transaction)->commit());
+    collection = (*data_source)->getCollection(*player_id);
+    ASSERT_TRUE(collection);
+    EXPECT_TRUE(collection->empty());
+}
+
+/// Verify unreleased prototype databases fail with actionable guidance.
+TEST(DataSourceSQLiteTest, RejectsObsoletePrototypeSchema)
+{
+    TemporaryDatabase database;
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE cards(id INTEGER PRIMARY KEY);"));
+    ASSERT_TRUE((*connection)->execute("PRAGMA user_version = 1;"));
+    connection->reset();
+
+    auto data_source = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(data_source);
+    auto version = (*data_source)->getSchemaVersion();
+    ASSERT_FALSE(version);
+    EXPECT_NE(version.error().msg().find("delete and recreate"),
+              std::string::npos);
+}
diff --git a/tests/email_sender_mailjet_test.cpp b/tests/email_sender_mailjet_test.cpp
new file mode 100644
index 0000000..6cee17c
--- /dev/null
+++ b/tests/email_sender_mailjet_test.cpp
@@ -0,0 +1,86 @@
+#include <chrono>
+#include <memory>
+#include <string>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/http_client_mock.hpp>
+
+#include "email_sender_mailjet.h"
+
+TEST(MailjetEmailSenderTest, ConfiguresAndSendsSuccessfulRequest)
+{
+    auto session = std::make_unique<mw::HTTPSessionMock>();
+    mw::HTTPSessionMock* mock = session.get();
+    EXPECT_CALL(*mock, allowedProtocols("https"))
+        .WillOnce(testing::Return(mw::E<void>{}));
+    EXPECT_CALL(*mock, allowedRedirectProtocols("https"))
+        .WillOnce(testing::Return(mw::E<void>{}));
+    EXPECT_CALL(*mock, followRedirects(false));
+    EXPECT_CALL(*mock, maxRedirections(0))
+        .WillOnce(testing::Return(mw::E<void>{}));
+    EXPECT_CALL(*mock, maxSize(64 * 1024))
+        .WillOnce(testing::Return(mw::E<void>{}));
+    EXPECT_CALL(*mock, connectionTimeout(std::chrono::seconds(5)))
+        .WillOnce(testing::Return(mw::E<void>{}));
+    EXPECT_CALL(*mock, transferTimeout(std::chrono::seconds(15)))
+        .WillOnce(testing::Return(mw::E<void>{}));
+    mw::HTTPResponse response(200, R"({"Messages":[{"Status":"success"}]})");
+    EXPECT_CALL(*mock, post(testing::_))
+        .WillOnce(testing::Invoke(
+            [&response](const mw::HTTPRequest& request)
+            {
+                EXPECT_EQ(
+                    request.url,
+                    "https://api.mailjet.com/v3.1/send");
+                EXPECT_EQ(
+                    request.header.at("Content-Type"),
+                    "application/json");
+                EXPECT_EQ(
+                    request.header.at("Authorization"),
+                    "Basic YXBpOnNlY3JldA==");
+                EXPECT_NE(request.request_data.find("person@example.com"),
+                          std::string::npos);
+                EXPECT_NE(request.request_data.find("confirm/token"),
+                          std::string::npos);
+                return mw::E<const mw::HTTPResponse*>(&response);
+            }));
+    MailjetEmailSender sender(
+        std::move(session),
+        "cards@example.com",
+        "Card Collection",
+        "api",
+        "secret");
+    ASSERT_TRUE(sender.configure());
+    auto url = mw::URL::fromStr("https://cards.example/confirm/token");
+    ASSERT_TRUE(url);
+
+    auto sent = sender.send({
+        "person@example.com",
+        std::move(*url),
+        std::chrono::system_clock::now() + std::chrono::minutes(10),
+    });
+
+    ASSERT_TRUE(sent) << sent.error().msg();
+    EXPECT_TRUE(sender.usesGlobalQuota());
+}
+
+TEST(MailjetEmailSenderTest, RejectsUpstreamFailureAndInvalidJson)
+{
+    auto session = std::make_unique<mw::HTTPSessionMock>();
+    mw::HTTPSessionMock* mock = session.get();
+    mw::HTTPResponse response(502, "failure");
+    EXPECT_CALL(*mock, post(testing::_))
+        .WillOnce(testing::Return(
+            mw::E<const mw::HTTPResponse*>(&response)));
+    MailjetEmailSender sender(
+        std::move(session), "a@example.com", "Cards", "api", "secret");
+    auto url = mw::URL::fromStr("https://cards.example/confirm/token");
+    ASSERT_TRUE(url);
+
+    EXPECT_FALSE(sender.send({
+        "person@example.com",
+        std::move(*url),
+        std::chrono::system_clock::now(),
+    }));
+}
diff --git a/tests/mvp_primitives_test.cpp b/tests/mvp_primitives_test.cpp
new file mode 100644
index 0000000..9e11fc0
--- /dev/null
+++ b/tests/mvp_primitives_test.cpp
@@ -0,0 +1,177 @@
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <filesystem>
+#include <fstream>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/crypto_mock.hpp>
+
+#include "authorization.h"
+#include "card_pool.h"
+#include "email_address.h"
+#include "email_sender_file.h"
+#include "secret_token.h"
+#include "username.h"
+
+namespace
+{
+
+Card card(std::int64_t id, std::int64_t rarity,
+          std::int64_t creator_user_id = 1)
+{
+    Card result{};
+    result.id = id;
+    result.rarity = rarity;
+    result.creator_user_id = creator_user_id;
+    return result;
+}
+
+User user(std::int64_t id, UserRole role)
+{
+    return {id, "user@example.com", "user@example.com", "User", role,
+            1, 0, 0};
+}
+
+TEST(EmailAddressTest, NormalizesEquivalentAsciiAddresses)
+{
+    auto address = normalizeEmail("  Name@Example.COM\t");
+    ASSERT_TRUE(address) << address.error().msg();
+    EXPECT_EQ(address->email, "Name@Example.COM");
+    EXPECT_EQ(address->key, "name@example.com");
+}
+
+TEST(EmailAddressTest, RejectsInvalidDotAtomAndDomainLabels)
+{
+    EXPECT_FALSE(normalizeEmail("a..b@example.com"));
+    EXPECT_FALSE(normalizeEmail("a@-example.com"));
+    EXPECT_FALSE(normalizeEmail("a@example..com"));
+    EXPECT_FALSE(normalizeEmail("a\xC3\xA9@example.com"));
+}
+
+TEST(UsernameTest, NormalizesAndFullyCaseFolds)
+{
+    auto composed = normalizeUsername("\xC3\xA9");
+    auto decomposed = normalizeUsername("e\xCC\x81");
+    ASSERT_TRUE(composed);
+    ASSERT_TRUE(decomposed);
+    EXPECT_EQ(composed->username, decomposed->username);
+
+    auto sharp_s = normalizeUsername("Stra\xC3\x9F" "e");
+    auto letters = normalizeUsername("STRASSE");
+    ASSERT_TRUE(sharp_s);
+    ASSERT_TRUE(letters);
+    EXPECT_EQ(sharp_s->key, letters->key);
+}
+
+TEST(UsernameTest, EnforcesBoundariesAndControls)
+{
+    EXPECT_TRUE(normalizeUsername(std::string(32, 'a')));
+    EXPECT_FALSE(normalizeUsername(std::string(33, 'a')));
+    EXPECT_FALSE(normalizeUsername(" name"));
+    EXPECT_FALSE(normalizeUsername("name\xE3\x80\x80"));
+    EXPECT_FALSE(normalizeUsername("bad\nname"));
+    EXPECT_FALSE(normalizeUsername(std::string("\xC3", 1)));
+}
+
+TEST(AuthorizationTest, EnforcesOwnershipAndRoleExceptions)
+{
+    AuthorizationService authorization;
+    const Card authored = card(10, 1, 2);
+    const User player = user(1, UserRole::PLAYER);
+    const User creator = user(2, UserRole::CREATOR);
+    const User other_creator = user(3, UserRole::CREATOR);
+    const User administrator = user(4, UserRole::ADMINISTRATOR);
+
+    EXPECT_TRUE(authorization.canViewCard(player, authored, true));
+    EXPECT_FALSE(authorization.canViewCard(player, authored, false));
+    EXPECT_TRUE(authorization.canViewCard(creator, authored, false));
+    EXPECT_FALSE(authorization.canEditCard(other_creator, authored));
+    EXPECT_TRUE(authorization.canEditCard(administrator, authored));
+    EXPECT_FALSE(authorization.canSetRarity(creator));
+    EXPECT_TRUE(authorization.canSetRarity(administrator));
+}
+
+TEST(CardPoolTest, CalculatesRequiredRarityRatio)
+{
+    CardPoolService pool;
+    const auto entries = pool.calculate({card(3, 2), card(2, 1), card(1, 0)});
+    ASSERT_EQ(entries.size(), 2);
+    EXPECT_EQ(entries[0].card.id, 2);
+    EXPECT_DOUBLE_EQ(entries[0].scaled_weight, 1.0);
+    EXPECT_DOUBLE_EQ(entries[1].scaled_weight, 0.5);
+    EXPECT_NEAR(entries[0].probability, 2.0 / 3.0, 1e-15);
+    EXPECT_NEAR(entries[1].probability, 1.0 / 3.0, 1e-15);
+}
+
+TEST(CardPoolTest, SelectsAtPortableRandomBoundaries)
+{
+    CardPoolService pool;
+    const auto entries = pool.calculate({card(1, 1), card(2, 1)});
+    mw::CryptoMock crypto;
+    EXPECT_CALL(crypto, randomBytes(8))
+        .WillOnce(testing::Return(std::vector<std::byte>(8)))
+        .WillOnce(testing::Return(std::vector<std::byte>{
+            std::byte{0xff}, std::byte{0xff}, std::byte{0xff},
+            std::byte{0xff}, std::byte{0xff}, std::byte{0xff},
+            std::byte{0xff}, std::byte{0xff}}));
+    auto first = pool.select(entries, crypto);
+    auto last = pool.select(entries, crypto);
+    ASSERT_TRUE(first);
+    ASSERT_TRUE(last);
+    EXPECT_EQ(first->card.id, 1);
+    EXPECT_EQ(last->card.id, 2);
+}
+
+TEST(CardPoolTest, FormatsProbabilityForCardViews)
+{
+    EXPECT_EQ(formatProbability(0), "0%");
+    EXPECT_EQ(formatProbability(1), "100%");
+    EXPECT_EQ(formatProbability(1.0 / 3.0), "33.333333%");
+    EXPECT_EQ(formatProbability(0.000000001), "<0.000001%");
+}
+
+TEST(SecretTokenTest, RejectsNonCanonicalCredentials)
+{
+    EXPECT_FALSE(hashSecretToken(std::string(63, '0')));
+    EXPECT_FALSE(hashSecretToken(std::string(64, 'A')));
+    EXPECT_TRUE(hashSecretToken(std::string(64, '0')));
+    EXPECT_TRUE(constantTimeEqual("secret", "secret"));
+    EXPECT_FALSE(constantTimeEqual("secret", "secreu"));
+    EXPECT_FALSE(constantTimeEqual("secret", "secret-long"));
+}
+
+TEST(FileEmailSenderTest, AtomicallyPublishesPrivateLatestLink)
+{
+    const std::filesystem::path target =
+        std::filesystem::path(testing::TempDir()) /
+        "card_collection_auth_link.txt";
+    std::error_code ignored;
+    std::filesystem::remove(target, ignored);
+    auto url = mw::URL::fromStr("http://127.0.0.1/confirm/secret");
+    ASSERT_TRUE(url);
+    FileEmailSender sender(target);
+
+    auto sent = sender.send({
+        "person@example.com",
+        std::move(*url),
+        std::chrono::system_clock::now(),
+    });
+
+    ASSERT_TRUE(sent) << sent.error().msg();
+    std::ifstream input(target);
+    std::string contents;
+    std::getline(input, contents);
+    EXPECT_EQ(contents, "http://127.0.0.1/confirm/secret");
+    const auto permissions = std::filesystem::status(target).permissions();
+    EXPECT_EQ(
+        permissions & (std::filesystem::perms::group_all |
+                       std::filesystem::perms::others_all),
+        std::filesystem::perms::none);
+    std::filesystem::remove(target, ignored);
+}
+
+} // namespace
diff --git a/tests/user_collection_test.cpp b/tests/user_collection_test.cpp
new file mode 100644
index 0000000..b05ac26
--- /dev/null
+++ b/tests/user_collection_test.cpp
@@ -0,0 +1,222 @@
+#include <chrono>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/crypto_mock.hpp>
+
+#include "collection.h"
+#include "data_sqlite.h"
+#include "game_registry.h"
+#include "startup.h"
+#include "user_service.h"
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+    TemporaryDatabase()
+        : path_(
+              std::filesystem::path(testing::TempDir()) /
+              ("collection_service_" + std::to_string(
+                  std::chrono::steady_clock::now()
+                      .time_since_epoch().count()) + ".sqlite3"))
+    {}
+
+    ~TemporaryDatabase()
+    {
+        std::error_code error;
+        std::filesystem::remove(path_, error);
+        std::filesystem::remove(path_.string() + "-shm", error);
+        std::filesystem::remove(path_.string() + "-wal", error);
+    }
+
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+class ClockMock final : public ClockInterface
+{
+public:
+    std::chrono::system_clock::time_point now() const override
+    {
+        return current;
+    }
+
+    std::chrono::system_clock::time_point current{};
+};
+
+mw::E<std::int64_t> insertPlayer(
+    DataSourceInterface& data_source,
+    const std::string& email,
+    std::uint32_t pulls = 1)
+{
+    auto transaction = data_source.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    User user = {
+        0,
+        email,
+        email,
+        std::nullopt,
+        UserRole::PLAYER,
+        pulls,
+        0,
+        0};
+    auto id = (*transaction)->insertUser(user);
+    if(!id)
+    {
+        return std::unexpected(std::move(id.error()));
+    }
+    auto username = (*transaction)->updateUsername(
+        *id, email.substr(0, email.find('@')), email);
+    if(!username)
+    {
+        return std::unexpected(std::move(username.error()));
+    }
+    auto commit = (*transaction)->commit();
+    if(!commit)
+    {
+        return std::unexpected(std::move(commit.error()));
+    }
+    return *id;
+}
+
+mw::E<std::int64_t> insertPoolCard(
+    DataSourceInterface& data_source,
+    std::int64_t creator_user_id,
+    std::uint32_t number,
+    std::int64_t rarity)
+{
+    auto transaction = data_source.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    Card card = {
+        0,
+        {std::nullopt, number},
+        "Pool card",
+        std::nullopt,
+        std::nullopt,
+        rarity,
+        "jpg",
+        std::nullopt,
+        "jpg",
+        1,
+        creator_user_id};
+    auto id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+    if(!id)
+    {
+        return std::unexpected(std::move(id.error()));
+    }
+    auto commit = (*transaction)->commit();
+    if(!commit)
+    {
+        return std::unexpected(std::move(commit.error()));
+    }
+    return *id;
+}
+
+} // namespace
+
+TEST(UserServiceTest, OnboardsAndPermanentlyPromotesPlayer)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto administrator = (*data_source)->getUserByEmailKey(
+        "admin@example.com");
+    ASSERT_TRUE(administrator);
+    ASSERT_TRUE(*administrator);
+    auto player_id = insertPlayer(**data_source, "player@example.com");
+    ASSERT_TRUE(player_id);
+    UserService users(**data_source);
+
+    auto renamed = users.setUsername(*player_id, "Stra\xC3\x9F" "e");
+    ASSERT_TRUE(renamed) << renamed.error().msg();
+    EXPECT_EQ(renamed->username, "Stra\xC3\x9F" "e");
+    auto promoted = users.promote((**administrator).id, *player_id);
+    ASSERT_TRUE(promoted) << promoted.error().msg();
+    EXPECT_EQ(promoted->role, UserRole::CREATOR);
+    auto repeated = users.promote((**administrator).id, *player_id);
+    ASSERT_TRUE(repeated);
+    EXPECT_EQ(repeated->role, UserRole::CREATOR);
+    EXPECT_FALSE(users.promote(*player_id, (**administrator).id));
+}
+
+TEST(CollectionServiceTest, AccruesCapsAndPullsAtomically)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto administrator = (*data_source)->getUserByEmailKey(
+        "admin@example.com");
+    ASSERT_TRUE(administrator);
+    ASSERT_TRUE(*administrator);
+    auto player_id = insertPlayer(**data_source, "collector@example.com");
+    ASSERT_TRUE(player_id);
+    auto card_id = insertPoolCard(
+        **data_source, (**administrator).id, 42, 1);
+    ASSERT_TRUE(card_id);
+    ClockMock clock;
+    mw::CryptoMock crypto;
+    EXPECT_CALL(crypto, randomBytes(8))
+        .WillOnce(testing::Return(std::vector<std::byte>(8)));
+    CollectionService collection(**data_source, clock, crypto, 3);
+
+    auto pulled = collection.pull(*player_id);
+    ASSERT_TRUE(pulled) << pulled.error().msg();
+    EXPECT_EQ(pulled->card.id, *card_id);
+    EXPECT_EQ(pulled->quantity, 1);
+    EXPECT_FALSE(collection.pull(*player_id));
+
+    clock.current = std::chrono::system_clock::time_point(
+        std::chrono::days(10));
+    auto refreshed = collection.refresh(*player_id);
+    ASSERT_TRUE(refreshed);
+    EXPECT_EQ(refreshed->stored_pulls, 3);
+    EXPECT_EQ(refreshed->pull_refresh_day, 10);
+    clock.current = std::chrono::system_clock::time_point(
+        std::chrono::days(9));
+    refreshed = collection.refresh(*player_id);
+    ASSERT_TRUE(refreshed);
+    EXPECT_EQ(refreshed->stored_pulls, 3);
+    EXPECT_EQ(refreshed->pull_refresh_day, 10);
+}
+
+TEST(CollectionServiceTest, EmptyPoolPreservesRefreshedPull)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto player_id = insertPlayer(**data_source, "empty@example.com");
+    ASSERT_TRUE(player_id);
+    ClockMock clock;
+    mw::CryptoMock crypto;
+    CollectionService collection(**data_source, clock, crypto, 3);
+
+    EXPECT_FALSE(collection.pull(*player_id));
+    auto user = (*data_source)->getUser(*player_id);
+    ASSERT_TRUE(user);
+    ASSERT_TRUE(*user);
+    EXPECT_EQ((**user).stored_pulls, 1);
+}