# 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)