Skip to content

Accounts & the login wall

Crimson Haven is members-only by default. This page explains the wall, the two ways to sign in, and how to invite people.

When REQUIRE_LOGIN=true (the default), every content endpoint requires a valid session token. A small whitelist stays public: the auth endpoints, /health, the Ko-fi webhook, and the signed stream proxies + /player (which <iframe>/<video> load without headers, so they’re protected by their HMAC signature instead). Validated tokens are cached briefly so the wall adds no database hit on hot paths.

Set REQUIRE_LOGIN=false to open the whole API (e.g. a public, no-accounts demo).

An account carries either an Ed25519 public key or an email + password hash.

No usernames, no passwords, no mail server. The account is a key derived from a 12-word BIP39 phrase that lives entirely on the user’s device (like P-Stream). The server stores only the public key and verifies signatures over one-time challenges — the phrase never reaches the backend, so a database leak exposes no credential.

There is no recovery. Lose the phrase, lose the account. Tell your members to write it down.

The client must derive keys exactly as the backend expects:

mnemonic : 12 BIP39 English words (128-bit entropy)
seed : PBKDF2-HMAC-SHA512(mnemonic, "mnemonic"+passphrase, 2048, dklen=64)
privSeed : seed[:32]
keypair : Ed25519 from privSeed (RFC 8032; == @noble/ed25519)
public_key: hex(publicKey) (64 lowercase hex chars) → the account id

Familiar, and supports verification + reset — but needs SMTP configured. Passwords are hashed with PBKDF2-HMAC-SHA256 (600k iterations). Verification and reset links are emailed as single-use, hashed tokens.

EndpointPurpose
POST /auth/email/registerCreate an invite-gated, unverified account → sends verification.
POST /auth/email/loginEmail + password → session (403 until verified).
POST /auth/email/verifyConsume a verification token → verified + a session.
POST /auth/email/resendResend verification (always 200, no account-exists oracle).
POST /auth/email/forgot / …/resetStart / complete a password reset.

Both account types require a valid invite to register, so the site stays private.

  • Shared codeSIGNUP_INVITE_CODE (comma-separated for several). Reusable. Empty ⇒ registration closed (403 for everyone).
  • Single-use codes — minted from the admin dashboard or the Discord bot; each registers exactly one account, then dies. Both kinds are accepted in the same signup field.

Accounts whose email is in ADMIN_EMAILS are promoted to admin on startup (so admin implies an email account). Admins get the dashboard: user management, invite minting, forced metadata re-sync, and health/source/proxy stats. After the first seed, admins can promote/demote others from the dashboard.

If you’ve woken Lumi’s chatbot, talking to her is a separate, per-account permission that starts denied for everybody, including admins. Admins hand it out one member at a time on Admin › Users (the bot icon beside the admin toggle; granted accounts wear a Lumi badge). There is no environment variable that grants it in bulk, because it’s a spending decision: each grant, revocation and budget change is written to the security ledger below.

Every denial at the gates is remembered. The backend keeps an append-only security-event log fed by the auth endpoints, the rate limiter, and the admin dashboard itself:

  • Failed & successful logins (both sign-in methods), blocked signups, and — the classic symptom of strangers probing — invalid invite codes.
  • Verification and password-reset activity, including requests for emails that don’t exist (only admins can read the ledger, so recording that re-opens no account-existence oracle).
  • Every rate-limit trip (someone hammering the auth endpoints is the strongest brute-force signal there is).
  • Admin actions — account deletions, admin grants/revocations, forced logouts, invite minting, bridge-key changes, and every Lumi grant, revocation, budget change or settings edit — a paper trail of the keepers themselves.

Admins read it under Admin › Security: 24-hour threat tiles, a per-day activity chart, the top offending IPs, the most-targeted identities, and the filterable raw ledger underneath (served by /admin/security/stats and /admin/security/events).

Two things are deliberately not logged, so signal beats noise: the site-wide login wall (every bot crawling the internet knocks on it — the ledger would drown in days), and the mnemonic login/register existence checks (they’re ordinary steps of the client’s sign-in flow, not attacks).

Writes are fire-and-forget — a logging failure can never break a login. Events store the client IP and the attempted identity: an email, or only the first 12 characters of a mnemonic public key — never passwords, tokens, or full keys. Rows are pruned after SECURITY_EVENTS_RETENTION_DAYS (default 90 days), which doubles as the privacy mechanism. The table is created automatically on the next deploy; there is nothing to migrate or switch on.

An optional, owner-only bot (python -m discord_bot) lets one whitelisted operator mint single-use invites with a chat command — handy for a community.

  1. Create a bot at the Discord Developer Portal, copy its token, and enable Message Content Intent.
  2. Set DISCORD_BOT_TOKEN and DISCORD_OWNER_ID (your numeric Discord user id). Only that user may use it.
  3. Run exactly one instance (a second login fights the first). In the bundled stacks it’s the discord-bot service.

DM the bot (default prefix !):

CommandAction
!invite [n]Mint n one-time invites (1–20, default 1).
!invitesList outstanding tokens.
!revoke <code>Delete an unused token.
!ping / !helpLiveness / usage.

With DISCORD_BOT_TOKEN unset the process just logs disabled and idles.

Favorites are show-level; watch progress is per-episode (auto-flips to completed past 90%). Both live in their own PostgreSQL tables, untouched by mapping resyncs. Members can export/import their lists from the Favorites menu.