Changes
diff --git a/designs/design-1-mvp.md b/designs/design-1-mvp.md
new file mode 100644
index 0000000..1addd9a
--- /dev/null
+++ b/designs/design-1-mvp.md
@@ -0,0 +1,1409 @@
+# MVP architecture
+
+## Status
+
+This document specifies the MVP implementation of Card Collection. It extends
+the prototype architecture in `design-0-prototype.md` with accounts,
+passwordless authentication, authorization, creator ownership, and the daily
+card-pull collection loop described in `prd.md`.
+
+The MVP remains a server-rendered C++ application. It does not add a public
+JSON API or a client-side application framework. Existing card rendering,
+image processing, game definitions, series handling, asset publication, and
+Markdown behavior continue to follow the prototype design except where this
+document explicitly changes access or persistence rules.
+
+This is an implementation design, not a new source of product requirements.
+If this document and `prd.md` disagree about user-visible behavior, `prd.md`
+wins and this document must be corrected.
+
+## Goals
+
+The MVP must:
+
+1. provide one passwordless email flow for registration and login;
+2. send authentication links through Mailjet in production and a file
+ transport in local development;
+3. store only hashes of authentication and session credentials;
+4. enforce single-use, ten-minute authentication links with an explicit POST
+ confirmation;
+5. create four-week, non-sliding sessions and support current-session logout;
+6. protect both individual email addresses and the Mailjet quota from request
+ floods;
+7. require a valid, unique Unicode username after first authentication;
+8. enforce the player, creator, and administrator permission hierarchy in the
+ service layer as well as the HTTP layer;
+9. bootstrap exactly one immutable administrator identity from configuration;
+10. give players a quantity-based card collection and lazily accrued pulls;
+11. choose each pull from the current positive-rarity pool using the required
+ rarity weights;
+12. consume a pull and increment the resulting card quantity atomically;
+13. display current pull probability to users allowed to view a card;
+14. preserve the creator of every card and restrict creator mutations to that
+ creator's cards;
+15. make administrator card deletion cascade to collection quantities; and
+16. provide deterministic service tests and end-to-end HTTP coverage for the
+ new security and collection behavior.
+
+## Non-goals
+
+The MVP does not include:
+
+- passwords, passkeys, OAuth, or social login;
+- changing a login email address;
+- revoking all sessions for an account;
+- account deletion, suspension, creator demotion, or administrator transfer;
+- an email outbox, delivery retry worker, bounce handling, or email analytics;
+- a `sendmail` transport or a generic development-mode switch;
+- card trading, gifting, decks, individual copy identities, or pull history;
+- premium enforcement below the application UI;
+- per-user authorization on the static card asset mount;
+- scheduled midnight jobs;
+- pagination, searching, or filtering of users and collections;
+- audit history for administrative or creator changes;
+- a public JSON API; or
+- horizontal scaling across multiple application processes.
+
+The last point follows from the prototype's single SQLite connection and
+in-process filesystem mutation coordination. The database rules in this
+document are still transaction-safe, but multi-process asset publication and
+operational rate limiting would need another design before horizontal scale.
+
+## Architectural decisions
+
+### Keep server-rendered HTML
+
+`App` continues to render complete Inja documents and accept native HTML form
+submissions. Authentication, pulls, username changes, promotion, card changes,
+and series changes are POST operations. Browser JavaScript remains limited to
+the existing WebGL card preview and image workflow.
+
+This keeps identity and authorization decisions in C++. A hidden button or a
+crafted request cannot bypass the permission checks because services re-read
+the acting user and enforce the same rules independently of page rendering.
+
+### Separate identity, authorization, and collection services
+
+The prototype's `CardService` and `SeriesService` remain responsible for their
+domain operations. The MVP adds focused services rather than placing account
+logic in `App`:
+
+- `AuthenticationService` owns challenges, email delivery, sessions, and
+ logout;
+- `UserService` owns administrator bootstrap, onboarding, username changes,
+ and creator promotion;
+- `AuthorizationService` expresses reusable permission decisions;
+- `CardPoolService` computes eligible-card weights and probabilities; and
+- `CollectionService` computes available pulls and performs atomic pulls.
+
+Services use domain values and `mw::E`; they do not accept `httplib::Request`
+or produce HTML. `App` parses cookies and forms, translates service errors to
+HTTP responses, and supplies template data.
+
+### Treat raw credentials as write-only secrets
+
+Authentication-link and session tokens are generated with
+`mw::CryptoInterface::randomBytes()`. The raw token is returned only to the
+caller that needs to place it in an email or cookie. SQLite stores its SHA-256
+digest, never the raw value. Application logs must never contain a raw token,
+session cookie, CSRF token, Mailjet secret, or full confirmation URL.
+
+Each token is 32 random bytes represented by 64 lowercase hexadecimal
+characters. This representation is URL-safe without custom escaping and gives
+256 bits of entropy. Parsing rejects any other length or character before a
+database lookup.
+
+### Make pull selection and mutation one transaction
+
+A pull is a single use case, not a read followed by unrelated writes. One
+`BEGIN IMMEDIATE` transaction:
+
+1. re-reads the user and role;
+2. applies lazy UTC accrual;
+3. reads the current eligible card pool;
+4. refuses the operation without changing the user when the pool is empty or
+ no pull is available;
+5. selects one card;
+6. persists the decremented pull state; and
+7. inserts or increments the collection row.
+
+No other writer can alter the pool or the user's pull count between those
+steps on the prototype's SQLite connection.
+
+### Keep static card assets public
+
+The `/static-cards` mount remains public and contains only published assets.
+The application authorizes `/cards/<public-id>` and edit/preview routes, but
+does not claim that knowledge of an asset URL is prevented. This is the
+specified application-UI restriction, not DRM.
+
+### Keep the pre-release schema at version 1
+
+The project has not released schema version 1. The MVP therefore replaces the
+development version-1 schema and its `migrateSchema0To1()` implementation
+instead of adding a version-2 migration. Existing prototype development
+databases must be deleted and recreated when this work lands. The startup
+error for an incompatible old version-1 layout should explicitly say this.
+
+The first released schema freezes version 1. Every schema change after that
+release must increment `DB_SCHEMA_VERSION` and provide an ordered migration;
+deleting a released database is not an acceptable migration strategy.
+
+## System overview
+
+```mermaid
+flowchart LR
+ Browser[Browser]
+ App[App routes and templates]
+ Auth[AuthenticationService]
+ Users[UserService]
+ Policy[AuthorizationService]
+ Cards[CardService and SeriesService]
+ Collection[CollectionService]
+ Pool[CardPoolService]
+ Email[EmailSenderInterface]
+ Mailjet[Mailjet HTTP API]
+ File[Development link file]
+ Data[DataSourceInterface]
+ DB[(SQLite)]
+ Assets[(Published card assets)]
+
+ Browser -->|GET and form POST| App
+ App --> Auth
+ App --> Users
+ App --> Policy
+ App --> Cards
+ App --> Collection
+ App -->|public static request| Assets
+ Auth --> Email
+ Email --> Mailjet
+ Email --> File
+ Auth --> Data
+ Users --> Data
+ Cards --> Data
+ Collection --> Pool
+ Collection --> Data
+ Pool --> Data
+ Cards --> Assets
+```
+
+`App` owns the long-lived services and their dependencies. Interfaces used in
+tests are injected as `std::unique_ptr` or non-owning references with a clear
+owner. `std::shared_ptr` is not needed.
+
+## Proposed source tree
+
+The following files are added or materially changed:
+
+```text
+src/
+├── app.cpp
+├── app.h
+├── authentication.cpp
+├── authentication.h
+├── authorization.cpp
+├── authorization.h
+├── card.h
+├── card_pool.cpp
+├── card_pool.h
+├── card_service.cpp
+├── card_service.h
+├── clock.cpp
+├── clock.h
+├── collection.cpp
+├── collection.h
+├── config.cpp
+├── config.h
+├── data.h
+├── data_fake.cpp
+├── data_mock.h
+├── data_sqlite.cpp
+├── data_sqlite.h
+├── email_sender.cpp
+├── email_sender.h
+├── email_sender_file.cpp
+├── email_sender_file.h
+├── email_sender_mailjet.cpp
+├── email_sender_mailjet.h
+├── secret_token.cpp
+├── secret_token.h
+├── user.cpp
+├── user.h
+├── user_service.cpp
+├── user_service.h
+├── username.cpp
+└── username.h
+templates/
+├── account.html
+├── authentication_confirm.html
+├── authentication_email.html
+├── authentication_sent.html
+├── collection.html
+├── error.html
+├── onboarding_username.html
+├── user_admin.html
+└── welcome.html
+tests/
+├── authentication_test.cpp
+├── authorization_test.cpp
+├── card_pool_test.cpp
+├── collection_test.cpp
+├── email_sender_file_test.cpp
+├── user_service_test.cpp
+└── username_test.cpp
+```
+
+Existing test executables may group these files differently. The important
+boundary is that pure services remain testable without a listening socket,
+real clock, Mailjet account, or production database.
+
+## Domain model
+
+### Users and roles
+
+```c++
+/// 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
+{
+ std::int64_t id;
+ std::string email;
+ std::string email_key;
+ std::optional<std::string> username;
+ UserRole role;
+ std::uint32_t stored_pulls;
+ std::int64_t pull_refresh_day;
+ std::int64_t created_at;
+};
+```
+
+`email` is the immutable destination supplied at the first successful login.
+`email_key` is the normalized unique key. A later login using equivalent
+casing sends to the submitted, validated destination but does not modify the
+stored account email.
+
+`username` is null only between first authentication and onboarding. The
+case-folded uniqueness key is an internal persistence field and need not be
+part of `User` returned to page code.
+
+Role ordering may be useful for display but must not be the only permission
+implementation. Named authorization functions make exceptional rules such as
+"creator may edit only their own card" explicit.
+
+### Cards
+
+`Card` gains:
+
+```c++
+/// Internal user ID of the account that created this card.
+std::int64_t creator_user_id;
+```
+
+Fresh cards created by a creator always receive the acting creator's ID and
+rarity zero. Fresh cards created by the administrator receive the
+administrator's ID; an administrator may choose any valid rarity. Existing
+forms never accept a creator ID.
+
+A creator request containing a rarity field is rejected with 400; the service
+must not merely hide that field in HTML. On update, a creator cannot change
+`creator_user_id`, rarity, game identity, or card number. The service preserves
+those values even if a future handler passes an unsafe input. The administrator
+may edit any card and its rarity but also cannot reassign its creator.
+
+### Collection summaries
+
+```c++
+/// One distinct card and the quantity owned by a user.
+struct CollectionEntry
+{
+ Card card;
+ std::int64_t quantity;
+};
+
+/// Current pool weight and normalized probability for one card.
+struct CardPoolEntry
+{
+ Card card;
+ double scaled_weight;
+ double probability;
+};
+```
+
+Collection queries return one row per distinct card. The collection template
+places `quantity` in the bottom-right of the existing thumbnail component.
+
+## Authorization model
+
+### Permission matrix
+
+| Operation | Anonymous | Player | Creator | Administrator |
+|---|---:|---:|---:|---:|
+| View welcome and example assets | yes | yes | yes | yes |
+| Request authentication email | yes | yes | yes | yes |
+| View own collection | no | yes | yes | yes |
+| Pull a card | no | yes | yes | yes |
+| View owned card | no | yes | yes | yes |
+| Preview authored unowned card | no | no | yes | yes |
+| Create card | no | no | yes | yes |
+| Edit own card | no | no | yes | yes |
+| Edit another creator's card | no | no | no | yes |
+| Set card rarity | no | no | no | yes |
+| Delete card | no | no | no | yes |
+| Manage series | no | no | no | yes |
+| List all users | no | no | no | yes |
+| Promote player to creator | no | no | no | yes |
+
+All authenticated operations also require a completed username. The only
+exceptions are username onboarding and logout. A logged-in user without a
+username is redirected to onboarding on safe GET requests and receives a
+conflict response on other POST routes; the handler must not execute the
+requested service first.
+
+### Authorization interface
+
+`AuthorizationService` contains side-effect-free functions such as:
+
+```c++
+/// Return whether an actor may view a card through the WebGL page.
+bool canViewCard(const User& actor, const Card& card,
+ bool actor_owns_card);
+
+/// Return whether an actor may edit the supplied card.
+bool canEditCard(const User& actor, const Card& card);
+
+/// Return whether an actor may create cards.
+bool canCreateCard(const User& actor);
+
+/// Return whether an actor may administer users and series.
+bool canAdminister(const User& actor);
+```
+
+Handlers use these functions to avoid rendering unusable controls. Mutation
+services also re-read the actor inside their transaction and enforce the
+corresponding rule. Authorization is therefore not based on stale session
+template data.
+
+For a missing or inaccessible card, the view and edit routes return the same
+404 page. This avoids confirming an unowned card's existence through the live
+application. It does not attempt to hide public static assets.
+
+## Persistence design
+
+### SQLite rules
+
+Startup enables `PRAGMA foreign_keys = ON` for every connection, as required
+for SQLite foreign-key enforcement. Foreign keys and cascades below depend on
+that setting; see the [SQLite foreign key documentation](https://www.sqlite.org/foreignkeys.html).
+
+All epoch times are signed 64-bit UTC seconds. A UTC day is
+`floor(epoch_seconds / 86400)` for non-negative production timestamps. Tests
+use an injected clock rather than the host timezone.
+
+### Schema
+
+The complete MVP additions to schema version 1 are:
+
+```sql
+CREATE TABLE application_metadata(
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+) STRICT;
+
+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;
+
+CREATE UNIQUE INDEX users_one_administrator
+ON users(role)
+WHERE role = 2;
+
+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;
+
+CREATE INDEX authentication_challenges_email
+ON authentication_challenges(email_key, expires_at);
+
+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;
+
+CREATE INDEX sessions_user ON sessions(user_id);
+CREATE INDEX sessions_expiry ON sessions(expires_at);
+
+CREATE TABLE authentication_email_limits(
+ email_key TEXT PRIMARY KEY,
+ next_allowed_at INTEGER NOT NULL
+) STRICT;
+
+CREATE TABLE authentication_quota(
+ utc_day INTEGER PRIMARY KEY,
+ attempted_sends INTEGER NOT NULL CHECK(attempted_sends >= 0)
+) STRICT;
+
+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;
+```
+
+The existing `cards` table gains:
+
+```sql
+creator_user_id INTEGER NOT NULL
+ REFERENCES users(id) ON DELETE RESTRICT
+```
+
+and an index used by creator management pages:
+
+```sql
+CREATE INDEX cards_creator ON cards(creator_user_id, id);
+CREATE INDEX cards_pool ON cards(rarity, id) WHERE rarity > 0;
+```
+
+The administrator partial unique index prevents a second administrator even
+if application logic fails. No web operation deletes users, so
+`creator_user_id` normally cannot block an MVP action. The restriction also
+prevents an accidental future user deletion from orphaning authorship.
+
+Card deletion continues to remove game extension rows, series memberships,
+and published assets. The new `card_holdings.card_id` cascade removes every
+user's quantity in the same database transaction.
+
+### Data source operations
+
+The persistence interface adds typed reads for:
+
+- a user by ID or normalized email key;
+- a session and joined user by session digest;
+- a valid authentication challenge by digest and current time;
+- a user's distinct collection entries;
+- cards authored by a user;
+- all users for the administrator page;
+- current positive-rarity cards ordered by internal ID; and
+- a card plus whether a given user owns it.
+
+Transaction operations add:
+
+- insert a user after confirmed authentication;
+- update username and uniqueness key;
+- promote a player to creator with a conditional update;
+- reserve an authentication email rate-limit slot;
+- insert, consume, and invalidate authentication challenges;
+- insert and delete sessions;
+- re-read user, card, ownership, and positive-rarity pool for authorization;
+- update stored pull state; and
+- upsert a collection quantity.
+
+Conditional SQL is preferred where it closes a race. Challenge consumption,
+for example, is one statement equivalent to:
+
+```sql
+UPDATE authentication_challenges
+SET consumed_at = :now
+WHERE token_hash = :token_hash
+ AND delivered_at IS NOT NULL
+ AND consumed_at IS NULL
+ AND expires_at > :now
+RETURNING email, email_key;
+```
+
+No returned row means invalid, expired, or already consumed. The response is
+the same for all three cases.
+
+### Cleanup
+
+No cleanup thread is required. Startup and authentication requests may delete:
+
+- sessions whose `expires_at <= now`;
+- consumed challenges older than one day;
+- unconsumed challenges whose `expires_at` is older than one day; and
+- email-limit rows whose next allowed time is older than one day.
+
+The current and previous UTC quota rows are enough operationally; older rows
+may be removed at startup. Cleanup is best effort and must not make a valid
+authentication or collection operation fail.
+
+## Email identity normalization
+
+The MVP accepts ASCII email addresses. This deliberately excludes SMTPUTF8
+local parts until internationalized email behavior is designed end to end.
+The normalization function:
+
+1. rejects embedded NUL, control bytes, non-ASCII bytes, and a total length
+ over 254 bytes;
+2. removes leading and trailing ASCII whitespace for form ergonomics;
+3. requires exactly one `@`, a non-empty local part, and a non-empty domain;
+4. limits the local part to 64 bytes, rejects leading, trailing, or adjacent
+ dots, and otherwise applies conservative dot-atom validation;
+5. limits the domain to 253 bytes and requires valid DNS-label syntax without
+ leading or trailing hyphens; and
+6. lowercases the complete address using ASCII rules to produce `email_key`.
+
+The validated, trimmed spelling remains `email` for initial delivery and
+account display. Lowercasing the whole key intentionally treats local-part
+case variants as one MVP identity, matching normal consumer-email behavior and
+preventing duplicate accounts that deliver to the same mailbox.
+
+The authentication request page always renders the same successful
+"check your email" response for registered and unregistered keys. Invalid
+syntax may receive a field validation error because it reveals no account
+state.
+
+## Username normalization
+
+The implementation links directly to `uni-algo::uni-algo`, already declared
+as floating `main` content in `cmake/dependencies.cmake`. It uses Unicode
+algorithms rather than locale-sensitive C or C++ character functions.
+
+Validation follows this exact order:
+
+1. decode the submitted bytes as strict UTF-8 and reject ill-formed input;
+2. normalize the decoded value to NFC;
+3. reject an empty normalized result;
+4. reject a normalized byte length over 32;
+5. reject any Unicode General Category `Cc` code point;
+6. reject a Unicode whitespace code point at either end without silently
+ trimming it;
+7. store the NFC value as `username`;
+8. full-case-fold that NFC value; and
+9. normalize the folded result to NFC again and store it as `username_key`.
+
+The final NFC pass matters because case mapping can change normalization.
+SQLite compares the precomputed UTF-8 `username_key` as binary data; its ASCII
+`NOCASE` collation is not used. The unique index is the final race-safe
+authority. A conflict returns the form with a generic "username is already in
+use" error.
+
+These choices follow Unicode's definitions of
+[normalization](https://www.unicode.org/reports/tr15/) and
+[default case folding](https://www.unicode.org/faq/casemap_charprop.html).
+The chosen uni-algo version and its Unicode data version should be logged at
+build or startup because upgrading Unicode tables can expose a previously
+unknown username-key collision. Such an upgrade requires a preflight query
+that recomputes all keys before deployment.
+
+## Authentication flow
+
+### Requesting a link
+
+`POST /authentication/email` performs these steps:
+
+1. parse and validate the email form field;
+2. compute `email_key`;
+3. verify the anonymous authentication-form CSRF token;
+4. begin an immediate transaction;
+5. reserve the per-email one-minute slot and, for Mailjet, one global daily
+ quota unit;
+6. generate 32 random bytes through `mw::CryptoInterface`;
+7. hex-encode them for the URL, hash the original bytes with
+ `mw::SHA256Hasher`, and insert a pending challenge expiring in ten minutes;
+8. commit, releasing the SQLite mutex before network I/O;
+9. build the absolute confirmation URL with the existing URL builder;
+10. ask the configured `EmailSenderInterface` to deliver it;
+11. mark the challenge delivered in a new transaction; and
+12. respond 303 to the neutral sent page.
+
+If the per-email slot is unavailable or the global quota is exhausted, the
+server returns 429 with `Retry-After`. The per-email row is updated whether or
+not the email belongs to a user. The global counter counts attempted calls to
+Mailjet, including upstream failures, because those calls consume operational
+capacity and a failure flood must not bypass the cap.
+
+The reservation transaction uses conditional upserts. The email operation is
+equivalent to:
+
+```sql
+INSERT INTO authentication_email_limits(email_key, next_allowed_at)
+VALUES(:email_key, :now + 60)
+ON CONFLICT(email_key) DO UPDATE
+SET next_allowed_at = excluded.next_allowed_at
+WHERE authentication_email_limits.next_allowed_at <= :now
+RETURNING next_allowed_at;
+```
+
+No returned row means that normalized address is still limited. For Mailjet,
+the same transaction conditionally inserts or increments the current UTC-day
+row only when `attempted_sends < configured_limit`. Failure of either
+reservation rolls back both, so a global-quota rejection does not unnecessarily
+start the address's minute. `Retry-After` is the remaining address interval or
+the seconds until the next UTC day for a quota rejection.
+
+If random generation, hashing, or challenge persistence fails, no send is
+attempted. If Mailjet or the file sender fails, a new transaction deletes the
+pending challenge and the user receives an immediate 502 error page. A pending
+challenge is never accepted by confirmation. There is no outbox and no retry.
+An already delivered older challenge remains valid; requesting another link
+does not invalidate earlier links until one succeeds.
+
+The daily Mailjet attempt cap is configurable and defaults to 180. This leaves
+headroom below Mailjet plans that may have a daily allowance while avoiding a
+hard-coded assumption about the subscribed plan. Operators must set the cap
+to a value appropriate for their current account. The database reservation is
+atomic across concurrent requests. File delivery retains the per-email minute
+limit but does not increment or enforce the Mailjet daily quota.
+
+`GET /authentication` generates a 32-byte anonymous form nonce. The response
+sets it in a short-lived, host-only, `HttpOnly`, `SameSite=Strict` cookie and
+places the same value in the email form. The POST requires a constant-time
+match. This prevents another site from spending quota by making a visitor's
+browser submit the form; direct automated abuse is still stopped by the
+server-side limits. The cookie has no account authority and is replaced each
+time the form is rendered.
+
+### Email sender interface
+
+```c++
+/// Contents needed to send one passwordless authentication email.
+struct AuthenticationEmail
+{
+ std::string recipient;
+ mw::URL confirmation_url;
+ 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;
+};
+```
+
+`MailjetEmailSender` uses an injected `mw::HTTPSessionInterface` and the
+Mailjet v3.1 `POST https://api.mailjet.com/v3.1/send` endpoint. It builds an
+`mw::HTTPRequest`, restricts protocols to HTTPS, disables redirects, limits
+the response body to 64 KiB, and configures a five-second connection timeout
+and fifteen-second transfer timeout. It sends HTTP Basic authentication using
+the API key and secret encoded by `mw::base64Encode`. A request succeeds only
+on a 2xx response whose parsed message status is successful. Returned
+diagnostic text is length-limited and stripped of credentials before inclusion
+in an internal error.
+
+The request contains one recipient, the configured sender, a stable login
+subject, a plain-text part, and an HTML part. Both bodies identify the ten
+minute expiry, contain the absolute confirmation link once, and tell an
+unexpected recipient to ignore the message. The recipient, link, sender name,
+and sender address are JSON encoded; the HTML body also HTML-escapes every
+substituted value.
+
+The API key and secret are read from named environment variables at startup,
+not stored in TOML. Startup fails before listening when the selected transport
+lacks its required configuration. The implementation follows Mailjet's
+[Send API reference](https://dev.mailjet.com/email/reference/) and
+[v3.1 endpoint guidance](https://dev.mailjet.com/email/reference/overview/versioning/index.html).
+
+`FileEmailSender` writes only the newest absolute confirmation URL followed by
+a newline. It writes a uniquely named sibling temporary file with owner-only
+permissions and atomically renames it over the configured target. Parent
+directories must already exist. This prevents the server from accidentally
+creating an arbitrary directory tree and avoids readers observing a partial
+token.
+
+### Opening and confirming a link
+
+`GET /authentication/confirm/<token>` validates the token's shape, decodes and
+hashes it, and performs a read-only valid-challenge lookup. It never consumes
+the token, creates a user, or creates a session. A valid token renders a page
+explaining which email will be authenticated and a form whose action is the
+same URL. The raw token is present only in that action URL.
+
+The GET also generates an independent confirmation-form nonce. It sets a
+short-lived, host-only, `HttpOnly`, `SameSite=Strict` cookie and includes the
+same value as a hidden form field. The POST requires a constant-time match and
+then clears the cookie. This prevents login CSRF, in which an attacker tries to
+log a victim's browser into the attacker's account. It changes only ephemeral
+browser cookie state, not the challenge or any server-side account state, so
+opening the link remains a non-consuming operation.
+
+The confirmation response sets `Referrer-Policy: no-referrer` and must not
+load third-party resources. Request logging replaces the token path component
+with `<redacted>`. These precautions reduce leakage through referrers and
+logs, though possession of an unexpired token remains the authentication
+factor.
+
+`POST /authentication/confirm/<token>`:
+
+1. validates and hashes the token;
+2. verifies the confirmation-form nonce cookie and field;
+3. begins an immediate transaction;
+4. conditionally consumes the challenge;
+5. loads the user by `email_key` or creates a player with one stored pull and
+ `pull_refresh_day` equal to the current UTC day;
+6. invalidates every other unconsumed challenge for that email key;
+7. generates and inserts a new four-week session;
+8. commits;
+9. sets the session cookie; and
+10. responds 303 to username onboarding when the username is null, otherwise
+ to the collection.
+
+Random unique-key collisions on challenge, session, or CSRF fields cause up to
+three complete token-generation retries. A fourth collision is treated as a
+cryptographic or database invariant failure and returns 500; an existing row
+is never overwritten.
+
+The administrator bootstrap row is found rather than recreated. A successful
+magic-link login creates a new session with a new four-week expiry. It neither
+extends nor deletes sessions on other devices. Reauthenticating in a browser
+that already has a session deletes that current session before inserting its
+replacement, but other sessions remain unchanged.
+
+Email-link scanners may issue GET requests, so the no-mutation GET/POST split
+is mandatory. The POST does not need an existing session CSRF token, but it
+does require the confirmation nonce above. The URL token proves authority over
+the email link; the nonce proves that this browser opened the confirmation
+page before submitting it.
+
+### Session cookies
+
+The cookie is named `card_collection_session`. It is host-only and uses:
+
+```text
+Path=<application base path>; HttpOnly; SameSite=Lax; Max-Age=2419200
+```
+
+`Secure` is mandatory when the configured base URL is HTTPS. Production
+Mailjet configuration requires an HTTPS base URL; an HTTP base URL is accepted
+only with the file transport on a loopback listener. Cookie behavior follows
+[RFC 6265](https://www.rfc-editor.org/rfc/rfc6265.html).
+
+For each authenticated request, the server parses exactly one cookie value,
+validates its shape, hashes it, and loads the joined session and user where
+`expires_at > now`. Ordinary requests never update `expires_at`. Invalid or
+expired credentials are treated as anonymous and receive an expired cookie to
+clear browser state.
+
+`POST /logout` verifies CSRF, deletes the current session by digest, clears the
+cookie with the same Path attributes, and redirects to the welcome page.
+
+### CSRF protection
+
+Every session stores an independent 32-byte hexadecimal `csrf_token`. Every
+authenticated mutation form includes it as a hidden `csrf_token` field. The
+handler compares the submitted and stored values in constant time before
+calling a mutation service. Missing, duplicated, malformed, or mismatched
+values return 403.
+
+This synchronizer-token defense applies to pulls, logout, username changes,
+card changes, series changes, promotion, and deletion. `SameSite=Lax` is
+supplemental rather than the sole defense, following the
+[OWASP CSRF guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html).
+
+## Administrator bootstrap
+
+`administrator_email` is required configuration. After schema initialization
+and before asset reconciliation, startup:
+
+1. normalizes the configured address using the normal email rules;
+2. begins an immediate transaction;
+3. reads `application_metadata['administrator_email_key']`;
+4. if absent, inserts the normalized key as immutable metadata;
+5. if present and unequal, rolls back and fails startup with an actionable
+ configuration error;
+6. loads the user by that key;
+7. creates the user with role `ADMINISTRATOR`, one pull, null username, and
+ the current UTC refresh day when absent;
+8. verifies that the existing user has the administrator role when present;
+9. verifies that exactly one administrator exists; and
+10. commits.
+
+The initial stored destination spelling is also retained in the user row. A
+configuration spelling that normalizes to the same key is accepted after
+initialization, but it does not change that row.
+
+No normal authentication transaction can assign `ADMINISTRATOR`. No web route
+can modify the metadata key, promote a creator to administrator, demote the
+administrator, or replace it.
+
+## Username onboarding and account flow
+
+After authentication, middleware classifies a request as anonymous, pending
+username onboarding, or fully onboarded. The middle state may access:
+
+- `GET` and `POST /onboarding/username`;
+- `POST /logout`; and
+- the static application assets needed to render those pages.
+
+Submitting a valid username updates `username` and `username_key` in one
+transaction, then responds 303 to `/collection`. A uniqueness conflict
+re-renders the form with status 409. Other users can never observe the login
+email through collection or card pages.
+
+`GET /account` displays immutable email and current username.
+`GET /account/username` renders the edit form, and
+`POST /account/username` applies the same normalization and uniqueness rules.
+Submitting the current username succeeds without a uniqueness false positive.
+
+## Card pool and probabilities
+
+### Eligible cards
+
+The pool is all rows in `cards` whose `rarity > 0`, without filtering by game,
+series, creator, or whether any user already owns the card. The transaction
+orders rows by stable internal `cards.id` so deterministic random values give
+deterministic tests.
+
+Rarity zero has weight zero and is excluded. Negative rarity is rejected by
+card validation. The administrator may move a card into or out of the pool by
+changing its rarity; creator edits preserve rarity.
+
+### Stable weight calculation
+
+The required individual weight is:
+
+```text
+w(card) = 2^(1 - rarity(card))
+```
+
+Normalizing every weight by the same positive constant does not change a
+probability. To delay underflow, `CardPoolService` finds the smallest eligible
+rarity and computes:
+
+```text
+scaled_weight(card) = 2^(minimum_rarity - rarity(card))
+probability(card) = scaled_weight(card) / sum(scaled_weight)
+```
+
+This is algebraically identical to the product formula while making the
+largest weight exactly one. Calculations use `double` and `std::exp2`. Both
+rarities are converted to `double` before subtraction so unsigned integer
+subtraction cannot wrap. Very large rarity gaps may still underflow to zero,
+which is accepted for the MVP. At least one weight is one, so the total cannot
+be zero for a non-empty pool.
+
+The card view calls the same `CardPoolService` used by pulls. It displays:
+
+- `Not currently in the pull pool` for rarity zero;
+- `0%` for a represented zero probability;
+- `<0.000001%` for a positive probability below that display threshold; or
+- a percentage with up to six fractional digits, without trailing zeroes.
+
+The probability is current when the page is rendered. It is not a historical
+promise about the next click because an administrator may change the pool
+before that click.
+
+### Random selection
+
+The service obtains an unsigned 64-bit integer from eight cryptographically
+random bytes supplied by `mw::CryptoInterface`. It uses the high 53 bits and
+`std::ldexp(value, -53)` to produce a portable fraction in the half-open
+interval `[0, 1)`. It multiplies that fraction by the total scaled weight and
+walks cumulative weights in card-ID order. Zero-weight entries are skipped. If
+floating-point accumulation leaves the target beyond the last boundary, the
+last positive-weight card is selected as a defensive fallback.
+
+Using cryptographic randomness is inexpensive at one sample per pull and
+avoids predictable awards. A deterministic `CryptoInterface` mock supplies
+boundary values in tests.
+
+## Lazy pull accrual
+
+`ClockInterface` exposes the current `system_clock::time_point`; production
+uses `SystemClock` and tests use `ClockMock`. For a user row and current UTC
+day, the effective state is:
+
+```text
+elapsed_days = max(0, current_day - pull_refresh_day)
+available = min(maximum_accumulated_pulls,
+ stored_pulls + elapsed_days)
+```
+
+The clock moving backwards does not remove pulls or move `pull_refresh_day`
+backward. `GET /collection` applies this calculation in a short transaction
+and persists `available` and `current_day` before rendering. This persistence
+is required to make days beyond the cap permanently lost. On a successful
+pull, the service stores `available - 1` and `current_day`.
+
+The configurable maximum must be at least one and fit in `uint32_t`. Lowering
+it clamps effective availability on the next collection refresh. Raising it
+does not restore days discarded by an earlier persisted refresh at the old
+cap. A cap change before a user has refreshed under the old value cannot
+reconstruct which historical limit applied; changing this setting therefore
+requires an operator-visible product decision. This caveat must be documented
+next to the setting.
+
+### Pull operation
+
+`POST /collection/pull` verifies authentication, onboarding, and CSRF, then
+calls `CollectionService::pull(user_id)`. Inside one immediate transaction:
+
+1. load the current user and reject a missing account;
+2. calculate effective pulls at the captured current UTC day;
+3. persist the refreshed availability and day;
+4. if availability is zero, commit the refresh and return
+ `NO_PULLS_AVAILABLE`;
+5. load the current positive-rarity pool;
+6. if it is empty, commit the refresh and return `EMPTY_CARD_POOL` without
+ consuming a pull;
+7. calculate weights and select a card;
+8. update the user's stored pulls to `available - 1` and refresh day to the
+ captured day;
+9. execute an upsert that increments `card_holdings.quantity`;
+10. fail safely on signed 64-bit integer overflow rather than wrapping; and
+11. commit and return the selected card identity and new quantity.
+
+Success responds 303 to the selected card page, which the user can now view.
+Two simultaneous pulls serialize; neither can spend the same availability.
+The pool used is the pool inside the winning transaction when the user clicks,
+not the pool from the earlier collection page.
+
+There is deliberately no award-history row. Logs may record user ID, card ID,
+and success for operations, but logs are operational data and not a product
+history interface.
+
+## Card and series mutation changes
+
+### Creator card flow
+
+The existing create/edit templates are reused, with server-rendered controls
+based on the actor:
+
+- creators see no rarity field and see a read-only explanation that new cards
+ remain outside the pool pending administrator review;
+- administrators see the rarity field;
+- neither sees or submits creator identity;
+- creators see only cards they authored on their management page; and
+- the existing WebGL local preview remains available before submission.
+
+`CardService` accepts an actor ID rather than a trusted role copied from the
+request. Before database mutation it re-reads the actor. Creation assigns
+creator and enforced rarity. Editing re-reads the card and checks ownership.
+The service returns `FORBIDDEN` if role or authorship changed since the page
+was rendered.
+
+Creator preview does not require collection ownership while the creator is on
+the authorized create or edit page. The normal `/cards/<public-id>` view is
+allowed for an owned card, its creator, or the administrator. This gives the
+creator a read-only view of an authored card without granting access to other
+unowned cards.
+
+### Administrator operations
+
+The administrator receives:
+
+- an all-card management index;
+- create and edit access with rarity controls;
+- card deletion with the existing confirmation form;
+- existing create/edit/delete series functionality; and
+- a user table containing username, immutable email, role, and promotion
+ control.
+
+Promotion uses a conditional `PLAYER -> CREATOR` update. Repeating it for a
+creator is an idempotent success or conflict, but never a demotion. An
+administrator row is not an eligible target. Promotion does not touch
+`card_holdings`, pull state, or sessions.
+
+Only the administrator handlers register series mutation routes. The
+`SeriesService` still checks the re-read actor so a future route cannot bypass
+the rule.
+
+## HTTP routes
+
+Routes are registered beneath the configured base URL. Existing public card
+identities and URL generation rules remain unchanged.
+
+| Method | Path | Access | Behavior |
+|---|---|---|---|
+| GET | `/` | all | Welcome, onboarding redirect, or collection redirect |
+| GET | `/authentication` | all | Email entry or reauthentication page |
+| POST | `/authentication/email` | all | Rate-limit and send a magic link |
+| GET | `/authentication/sent` | all | Neutral check-email page |
+| GET | `/authentication/confirm/<token>` | all | Read-only confirmation page |
+| POST | `/authentication/confirm/<token>` | all | Consume token and establish session |
+| POST | `/logout` | session | Revoke current session |
+| GET | `/onboarding/username` | pending onboarding | Username form |
+| POST | `/onboarding/username` | pending onboarding | Set initial username |
+| GET | `/account` | onboarded | Account summary |
+| GET | `/account/username` | onboarded | Username edit form |
+| POST | `/account/username` | onboarded | Change username |
+| GET | `/collection` | onboarded | Distinct owned-card index and pulls |
+| POST | `/collection/pull` | onboarded | Atomically pull one card |
+| GET | `/cards/<public-id>` | authorized | WebGL card view and probability |
+| GET | `/creator/cards` | creator/admin | Authored-card management index |
+| GET | `/cards/new` | creator/admin | Card creation form |
+| POST | `/cards` | creator/admin | Create card under acting creator |
+| GET | `/cards/<public-id>/edit` | author/admin | Card edit form |
+| POST | `/cards/<public-id>/edit` | author/admin | Update card |
+| POST | `/cards/<public-id>/delete` | admin | Delete card and holdings |
+| GET | `/admin/cards` | admin | All-card management index |
+| GET | `/admin/series` | admin | Series index |
+| GET/POST | `/admin/series/...` | admin | Existing series mutation flows |
+| GET | `/admin/users` | admin | User table |
+| POST | `/admin/users/<id>/promote` | admin | Permanently promote player |
+
+The old prototype root card index moves to `/admin/cards`; no anonymous live
+index remains. Static example cards on the welcome page are bundled application
+assets and are not queried from `cards`.
+
+### Response and error policy
+
+Successful form mutations use 303 redirects so refresh performs a GET.
+Expected failures use:
+
+| Status | Meaning |
+|---:|---|
+| 400 | malformed form, token shape, email, or username |
+| 401 | authentication required for a non-browser-style request |
+| 403 | valid session lacks role, or CSRF failed |
+| 404 | card/user route target is missing or intentionally concealed |
+| 409 | username conflict, stale role transition, no pulls, or pool became empty |
+| 413 | existing upload-size limits exceeded |
+| 429 | per-email interval or global email quota exceeded |
+| 500 | local persistence, random source, template, or invariant failure |
+| 502 | configured email transport failed |
+
+Safe browser GETs that require authentication redirect to `/authentication`.
+Successful authentication goes to onboarding or the collection rather than
+preserving an arbitrary return URL. POSTs never redirect to login because
+silently replacing a mutation with authentication is confusing.
+
+Errors shown to users contain no SQLite statement, token digest, filesystem
+secret, Mailjet body, or email-account existence information. Internal logs
+include a request correlation ID and stable error category.
+
+## Configuration
+
+The TOML configuration gains:
+
+```toml
+administrator_email = "admin@example.com"
+maximum_accumulated_pulls = 3
+
+[email]
+transport = "mailjet" # or "file"
+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
+
+# Required only when transport = "file".
+link_file = "/absolute/private/path/latest-authentication-link.txt"
+```
+
+Unknown keys remain errors. Validation rules are:
+
+- administrator email must normalize successfully;
+- maximum accumulated pulls must be in `[1, UINT32_MAX]`;
+- transport must be exactly `mailjet` or `file`;
+- Mailjet requires sender address, both environment-variable names, present
+ non-empty secrets, HTTPS base URL, and a positive daily limit;
+- file transport requires an absolute link path with an existing parent;
+- file transport rejects Mailjet-only secret settings to expose mistakes; and
+- HTTP with file transport requires a loopback TCP listener or Unix socket.
+
+The example configuration documents that changing administrator email after
+database initialization prevents startup, and that lowering the pull cap can
+discard accrued availability.
+
+## Build changes
+
+`cmake/dependencies.cmake` changes `LIBMW_BUILD_CRYPTO` to `ON`. The main
+binary and relevant tests link `mw::crypto`. The already declared uni-algo
+content is made directly available and the main binary links
+`uni-algo::uni-algo` rather than relying on a transitive include path.
+
+Mailjet reuses `mw::HTTPSessionInterface` from the already linked `mw::url`
+target rather than adding another HTTP abstraction. JSON construction and
+response parsing link directly to `nlohmann_json::nlohmann_json` rather than
+relying on Inja's transitive use. No dependency is pinned, consistent with the
+repository policy; dependencies continue using their appropriate floating
+non-default branch where already configured.
+
+## Startup order
+
+`main` performs startup in this order:
+
+1. parse and validate configuration without logging secrets;
+2. initialize logging and ImageMagick;
+3. compile the game registry;
+4. open SQLite and enable foreign keys and existing pragmas;
+5. create the complete MVP schema version 1 for an empty database;
+6. detect and reject the obsolete prototype version-1 shape with deletion
+ instructions;
+7. reconcile the configured administrator transactionally;
+8. reconcile persisted games and card asset directories;
+9. construct and validate the selected email sender;
+10. construct services, `App`, routes, and static mounts; and
+11. start listening.
+
+The process must not listen if administrator reconciliation or email transport
+configuration fails. It does not call Mailjet merely to test credentials at
+startup because that would consume quota and make startup depend on an
+unnecessary remote request.
+
+## Concurrency and transaction boundaries
+
+The existing data source mutex and `BEGIN IMMEDIATE` behavior remain. Important
+boundaries are:
+
+- email quota reservation and challenge insertion commit before remote email
+ I/O, so SQLite is never locked during a network call;
+- challenge consumption, user creation, other-challenge invalidation, and
+ session insertion are one transaction;
+- username-key uniqueness is decided by the database constraint;
+- role and card authorship are re-read inside every privileged mutation;
+- pool read, random choice, pull decrement, and holding increment are one
+ transaction; and
+- card deletion and holding cascades commit before the existing asset cleanup
+ compensation finishes.
+
+A remote Mailjet timeout may occur after Mailjet accepted the message but
+before the application received success. The application marks that new
+challenge unusable and reports failure because it cannot know the result.
+This can produce an email with an invalid link; the page tells the user to wait
+one minute and request another. An outbox or idempotent delivery protocol is
+outside the MVP.
+
+A process crash after successful delivery but before the delivered flag
+commits also produces an invalid emailed link. Keeping pending challenges
+unusable is safer than accepting a token whose delivery result the process did
+not durably record.
+
+## Security and privacy rules
+
+- Production authentication links and cookies travel only over HTTPS.
+- Raw magic, session, and CSRF tokens are never logged.
+- Email addresses are not present in public URLs.
+- Confirmation pages set `Cache-Control: no-store` and
+ `Referrer-Policy: no-referrer`.
+- A production reverse proxy must redact or disable access logging for
+ `/authentication/confirm/*`, because the raw token is a path component.
+- Authenticated and onboarding pages set `Cache-Control: private, no-store`.
+- Responses set a restrictive Content Security Policy compatible with the
+ existing local WebGL scripts and images; confirmation pages use a stricter
+ policy with no third-party sources.
+- Mailjet credentials exist only in process environment and sender memory.
+- Authentication responses do not reveal account existence.
+- Application logs prefer internal user IDs. When an email is required for an
+ operator error, it is redacted to a short hash and domain.
+- The server does not trust `X-Forwarded-For` for security decisions. The
+ persistent global Mailjet attempt cap, not a spoofable source address, is
+ the quota backstop.
+- The file email transport target is private operator data and must not be
+ inside either static root.
+
+Session handling should also follow the
+[OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
+
+## Testing strategy
+
+### Pure unit tests
+
+`username_test` covers:
+
+- invalid UTF-8;
+- canonically equivalent NFC inputs;
+- full case-fold collisions such as multi-code-point folds;
+- empty values;
+- exactly 32 and more than 32 normalized UTF-8 bytes;
+- leading/trailing Unicode whitespace;
+- interior whitespace;
+- every relevant control-category boundary; and
+- a case-fold result that requires final normalization.
+
+`authorization_test` table-tests every permission-matrix cell, including
+creator ownership and player ownership.
+
+`card_pool_test` covers:
+
+- empty and rarity-zero pools;
+- equal rarity probabilities;
+- adjacent rarity producing a two-to-one ratio;
+- all compiled games and loose cards participating;
+- extreme rarity gaps and represented zero weights;
+- first, exact-boundary, middle, and maximum random integers; and
+- the last-positive-entry rounding fallback.
+
+`collection_test` uses a fake clock and crypto source to cover:
+
+- a new account's initial pull;
+- accrual across one and several UTC boundaries;
+- registration immediately before midnight;
+- cap enforcement and lost excess days;
+- backward clock movement;
+- empty pool preserving a pull;
+- no-pull rejection;
+- new holding insertion and existing quantity increment;
+- card pool changes between page render and click;
+- quantity overflow rollback; and
+- two serialized pulls not double-spending availability.
+
+### Authentication service tests
+
+Tests use `EmailSenderMock`, `CryptoMock`, and `ClockMock` to verify:
+
+- registered and unregistered requests have indistinguishable success pages;
+- one normalized email cannot reserve twice within sixty seconds;
+- casing variants share the same limit;
+- global quota reservation is atomic and resets at a UTC boundary;
+- sender failure invalidates only the new challenge;
+- pending challenges cannot be confirmed before delivery activation;
+- invalid, expired, consumed, and unknown tokens have one result;
+- GET-style validation causes no mutation;
+- first confirmation creates exactly one player and one session;
+- concurrent confirmation consumes a challenge once;
+- later login reuses the user and starts a new four-week session;
+- ordinary authentication checks do not slide session expiry;
+- current logout leaves other sessions intact; and
+- no returned or logged error contains any raw secret.
+
+### Data source tests
+
+SQLite integration tests create a fresh temporary database and verify:
+
+- the complete version-1 schema and foreign-key setting;
+- exactly one administrator constraint;
+- immutable administrator metadata reconciliation;
+- username-key uniqueness under concurrent attempts;
+- session and challenge digest constraints;
+- holding primary key and positive quantity checks;
+- card deletion cascades holdings; and
+- creator user deletion is restricted while authored cards exist.
+
+The obsolete prototype-schema detection gets a fixture representing the old
+version-1 table layout and asserts the actionable startup error.
+
+### HTTP integration tests
+
+`app_integration_test` covers full request/response flows:
+
+- anonymous welcome and denial of live card pages;
+- magic-link request, read-only GET, POST confirmation, cookie attributes,
+ onboarding, and logout;
+- missing or mismatched anonymous and confirmation-form nonces;
+- expired session cleanup;
+- missing, duplicated, and bad CSRF fields;
+- collection quantities and pull redirect;
+- player denial of creator and administrator routes;
+- creator create/edit of own card and denial for another creator's card;
+- creator rarity tampering being rejected without changing the card;
+- administrator rarity, deletion, series, and promotion flows;
+- unowned and unknown cards returning indistinguishable 404 responses; and
+- probability appearing only on an authorized card page.
+
+No automated test calls Mailjet. A narrowly scoped manual staging test checks
+one real message after configuration, while `MailjetEmailSender` unit tests
+inject `mw::HTTPSessionMock` to cover the exact request, JSON, timeout setup,
+and response handling.
+
+### Manual acceptance checks
+
+Before declaring the MVP complete:
+
+1. start with a fresh database and the file email transport;
+2. authenticate and onboard the configured administrator;
+3. authenticate a new player and verify the initial pull;
+4. promote that player and create a card as the resulting creator;
+5. verify its forced rarity zero and absence from the pool;
+6. assign positive rarity as administrator;
+7. pull until the card is awarded and observe quantity changes;
+8. verify current probability in the authorized WebGL view;
+9. edit the card as its creator and observe the change in the owner's view;
+10. delete it as administrator and verify every holding disappears;
+11. cross a controlled UTC date boundary and verify one lazy accrual; and
+12. run the complete CTest suite under sanitizers when practical.
+
+## Requirements traceability
+
+| `prd.md` requirement group | Design sections | Primary verification |
+|---|---|---|
+| Access and permissions | Authorization model; card and series mutation changes; HTTP routes | Authorization unit table and HTTP role tests |
+| Registration and sessions | Email normalization; authentication flow; configuration | Authentication service and full browser-flow tests |
+| Usernames and onboarding | Username normalization; onboarding and account flow | Unicode unit tests and uniqueness integration tests |
+| Administrator account | Persistence schema; administrator bootstrap | Fresh-start and mismatch integration tests |
+| Card collection | Card pool; lazy accrual; pull operation | Pool, clock, transaction, and HTTP tests |
+
+The completion pass must compare individual `prd.md` bullets, not treat this
+grouped table as a substitute. A requirement with no implementation location
+and no verification location blocks milestone completion.
+
+## Implementation sequence
+
+The work should be delivered in dependency order:
+
+1. add crypto, uni-algo, direct JSON linkage, and configuration fields;
+2. replace the unreleased schema-v1 migration and extend data mocks;
+3. add clock, token, email, and username primitives with unit tests;
+4. implement administrator bootstrap and username onboarding;
+5. implement challenges, Mailjet/file senders, sessions, cookies, and CSRF;
+6. add authorization middleware and service-layer permission checks;
+7. add card creator persistence and role-aware card/series pages;
+8. implement pool weighting, probability display, accrual, and atomic pulls;
+9. implement the collection, account, creator, and administrator templates;
+10. add integration and manual acceptance coverage; and
+11. remove or redirect prototype routes that expose the live index.
+
+Each step leaves the build and tests passing. Schema replacement should land
+before code begins relying on new columns, and route exposure should not land
+before service-layer authorization is present.
+
+## MVP completion criteria
+
+The milestone is complete only when:
+
+- every MVP requirement in `prd.md` is represented by implementation and an
+ automated or explicit manual test;
+- anonymous users cannot discover live cards through application routes;
+- authentication secrets are random, hashed at rest, expiring, single-use,
+ and absent from logs;
+- Mailjet and file transports both implement the same sender interface;
+- sessions and every authenticated mutation use the specified cookie and CSRF
+ behavior;
+- all users must complete Unicode-safe username onboarding;
+- exactly one configured administrator is enforced at startup and in SQLite;
+- creator authorship and rarity restrictions survive crafted form requests;
+- collection pulls are atomic and use the current weighted pool;
+- authorized card views show probability from the same pool calculation;
+- card deletion cascades collection quantities; and
+- a clean configure, build, and full test run succeeds from a fresh database.
+
+## External references
+
+- [Mailjet Send API](https://dev.mailjet.com/email/reference/)
+- [Mailjet API key and secret key](https://documentation.mailjet.com/hc/en-us/articles/360043225693-What-is-an-API-key)
+- [libmw repository](https://github.com/MetroWind/libmw)
+- [Unicode Normalization Forms](https://www.unicode.org/reports/tr15/)
+- [Unicode case mapping and folding FAQ](https://www.unicode.org/faq/casemap_charprop.html)
+- [SQLite foreign keys](https://www.sqlite.org/foreignkeys.html)
+- [HTTP semantics, RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html)
+- [Cookies, RFC 6265](https://www.rfc-editor.org/rfc/rfc6265.html)
+- [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
+- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
diff --git a/prd.md b/prd.md
index ce5417b..097dbf2 100644
--- a/prd.md
+++ b/prd.md
@@ -244,20 +244,110 @@ libmw provides handling for the listening address and port.
## MVP requirements
-The MVP adds user registration and a permission hierarchy. Creators have all
-player permissions, and admins have all creator permissions.
-
-- Unregistered visitors may view cards.
-- Players may view and collect cards.
+The MVP adds passwordless user registration, a permission hierarchy, and a
+daily card-pull collection loop. Creators have all player permissions, and the
+single administrator has all creator permissions.
+
+### Access and permissions
+
+- Unregistered visitors see a welcome page containing application information
+ and optional static example cards. They cannot browse the live card index or
+ application card pages.
+- Players have an index containing the distinct cards in their collection.
+ Each thumbnail displays the quantity owned at its bottom-right corner.
+- A player may open the WebGL card view only for a card in their collection.
+ The card view also displays that card's current daily-pull probability.
+- WebGL view access is an application-UI restriction rather than a digital
+ rights management boundary. The application authorizes card-view requests,
+ but the underlying static-card asset mount does not need per-user access
+ control.
- Creators may create cards and modify cards they created. They cannot delete
- cards.
-- Admins may manage all cards, series, and user accounts.
-- If no admin account exists, visitors are prompted to create the first admin
- account. This bootstrap flow has no additional access or concurrency
- protection in the MVP; an operator may reset the database to recover from
- a failed bootstrap.
-- A player may own multiple copies of the same card.
-
-Registration approval, email verification, password and session policy,
-card acquisition and loss, trading and transfers, collection views, and
-collection privacy remain to be designed after the prototype.
+ cards, set rarity during creation, or modify rarity later. A creator may use
+ the existing WebGL preview while creating or editing one of their cards even
+ when they do not own that card.
+- Cards created by creators begin at rarity zero. Only the administrator may
+ change rarity, so a creator's card does not enter the daily pool until the
+ administrator assigns it a positive rarity.
+- Only the administrator may create, modify, or delete series.
+- The administrator may view and manage every card, series, and user account.
+ The administrator may promote a player to creator. Creator promotion is
+ permanent in the MVP; there is no demotion operation.
+- Promotion does not modify the user's card collection.
+
+### Registration and sessions
+
+- Registration and login use the same passwordless flow. A visitor submits an
+ email address and receives an authentication link. An account row is created
+ only after the link is successfully confirmed, except for the administrator
+ bootstrap row described below.
+- Authentication links contain a cryptographically random, single-use token
+ which expires after ten minutes. Opening the link performs no mutation. It
+ renders a confirmation page whose POST action consumes the token and logs in
+ or registers the user.
+- A login session expires four weeks after the user's most recent successful
+ magic-link login. Ordinary authenticated requests do not extend its expiry.
+ Logging out revokes the current session. Revoking every session belonging to
+ an account is outside the MVP.
+- Authentication email requests are limited to one per normalized email
+ address per minute, whether or not that address is already registered. The
+ implementation must also protect the Mailjet quota from request floods.
+- Login email is sent through [Mailjet](https://www.mailjet.com)'s HTTP API.
+ A Mailjet failure returns an error to the requester; the MVP does not retain
+ an email outbox or retry failed delivery.
+- Local development selects a file email transport through configuration. It
+ writes the generated authentication link to a configured file. There is no
+ general-purpose development mode and no `sendmail` transport.
+- Login email addresses cannot be changed.
+
+### Usernames and onboarding
+
+- After first authentication, a user must choose a username before performing
+ any other authenticated operation, including pulling a card.
+- Usernames are stored as UTF-8, normalized to Unicode NFC, limited to 32 bytes
+ after normalization, and compared for uniqueness with full Unicode case
+ folding.
+- Usernames may contain Unicode characters, but cannot be empty, contain
+ Unicode control characters, or have leading or trailing whitespace.
+- Users may change their username, subject to the same validation and
+ uniqueness rules.
+
+### Administrator account
+
+- The server has exactly one administrator. Its email address is required in
+ the configuration file and is immutable after the database is initialized.
+- At startup, the server compares the configured normalized administrator
+ email with the value retained by the database. Startup fails if they differ.
+- If the administrator email has no account row during initial startup, the
+ server creates an administrator row for it. The administrator completes the
+ same magic-link login and username onboarding flow as any other user.
+- The administrator cannot be demoted or replaced through the web UI.
+
+### Card collection
+
+- A new account starts with one available card pull. At every 00:00 UTC date
+ boundary, one pull becomes available, up to a configurable accumulated-pull
+ limit whose default is three.
+- Pull availability is calculated lazily when the user visits; no midnight
+ background task is required. Missed pulls beyond the configured limit are
+ lost. A user registering shortly before 00:00 UTC can use the initial pull
+ and receive another after the date boundary.
+- The authenticated collection UI displays the number of available pulls. A
+ user clicks a button to consume one pull and receive one random card.
+- When the daily pool is empty, the pull button is disabled and no available
+ pull is consumed.
+- The pull uses the card pool as it exists when the user clicks. Every card
+ with rarity greater than zero participates, including loose cards and cards
+ from every compiled game and creator.
+- Each individual eligible card has weight `2^(1 - rarity)`. Its displayed
+ probability is its weight divided by the sum of all eligible card weights at
+ the time the card view is rendered. Floating-point weights which are too
+ small to represent may round to zero in the MVP.
+- A player may own multiple copies of the same card. Ownership is stored as a
+ quantity for each user and card rather than as individually identified
+ copies.
+- Pulling a card atomically consumes one available pull and increments the
+ corresponding collection quantity. The MVP does not retain a separate daily
+ award history.
+- Editing a card changes its presentation for every user who owns it. When the
+ administrator deletes a card, that card and all collection quantities for it
+ are deleted.