Sign inStart creating

Accounts, login & configuration

Implementation: src/server/services/auth.ts, accounts.ts, oauth.ts, totp.ts. UI: /signin, /signup, /settings/**.

One identity, many roles

The same account is viewer, creator, org member and — where granted — reviewer or admin. Capabilities are role flags, not separate logins. The admin realm still requires its own gate and 2FA (see docs/admin/console.md).

Sign-in methods

MethodState
Email / username + passwordWorking. scrypt with a per-user salt.
Google OAuthFlow complete (PKCE, state, linking). Needs client credentials.
X OAuthSame. X does not return email under its v2 scopes, so the no-email path is first-class.
TOTP 2FAWorking, dependency-free (RFC 6238).

Sign-in accepts email or handle, which is what makes the TEST / ADMIN demo credentials work as usernames.

Unknown-user and wrong-password return an identical message. Distinguishing them hands an attacker a free account-enumeration oracle.

Account linking

An OAuth sign-in resolves in this order:

  1. Known identity → sign in.
  2. Provider-verified email matches an existing account → link.
  3. Otherwise → create a new account.

Step 2 is the dangerous one. Linking on an unverified email would let anyone who can obtain a token for an address take over the matching account, so we link only when the provider asserts verification.

Step 3 carries a second rule, added after the adversarial suite caught a crash: a new account only claims the provider's email when it is verified and unused. Otherwise it gets a provider-scoped synthetic address. This avoids two failure modes:

  • An unverified address that already exists → unique-constraint crash on sign-in. This was a real bug.
  • An unverified address that does not exist → squatting an address the signer-in may not own, locking out its real owner later.

Unlinking is refused when it would leave no way to sign in.

Demo accounts

TEST / TEST and ADMIN / ADMIN (Part 7.2).

The important detail: they are blocked at login, not merely omitted from the production seed. Even if a production database somehow inherits the rows, demoLoginsAllowed() refuses the credential pair. That is the difference between a convenience feature and a backdoor.

NODE_ENV=production          → refused
ALLOW_DEMO_LOGINS=true       → allowed (explicit opt-in)
ALLOW_DEMO_LOGINS=false      → refused everywhere (opt-out wins)

They are watermarked in the top bar and in Settings, and cannot be deleted.

Two-factor

Standard TOTP: 30-second steps, 6 digits, ±1 step for clock skew, constant-time comparison.

Enrollment stores the secret but leaves totpEnabled false until a code is confirmed — so a half-finished setup can never lock someone out.

QR rendering is a deliberate omission (it needs a client-side QR library). The UI shows the secret and the otpauth:// URI for manual entry, which every authenticator supports, and says so.

Sessions

Opaque random tokens in an httpOnly cookie, with user agent, IP and last-seen recorded. Users see a device list and can revoke individually or sign out everywhere else.

Two events destroy every session, both tested:

  • Password reset. The whole point is that the old holder loses access.
  • Suspension or ban. Without this the action does nothing until the existing cookie expires.

Email delivery

There is no mail transport configured. issueEmailVerification and requestPasswordReset return the token so the dev UI can surface it, clearly labelled as a development affordance. Once SMTP_URL or RESEND_API_KEY is set, deliverEmail sends and the token must stop being returned to the caller.

The forgot-password response is identical whether or not the address exists.

Card management

Card entry uses Stripe Elements, which renders inside an iframe served by Stripe so card data never reaches our servers, database or logs. We store only the payment-method token and customer id.

When Stripe is unconfigured the panel renders no card field at all — not a styled placeholder. A look-alike input trains people to type card numbers into a box that is not PCI-scoped, and someone would eventually wire it to a POST. The panel explains why it is absent.

Preferences

Three sections — notifications, viewer, creator — deep-merged against defaults so a newly added key never reads as undefined for existing accounts.

Notification preferences are honoured at write time: a disabled event is not recorded at all, rather than recorded and hidden.

Kids profiles

A Kids profile filters to G and PG, and Children's is a curated-only genre creators cannot self-select.

Leaving a Kids profile requires the PIN. The lock lives on the user record, not the query string — otherwise a child escapes by deleting ?kids=1 from the URL.

Privacy & data

Download my data exports profile, projects, generations, credit ledger, watch history, ratings, comments, sessions and connected accounts. OAuth tokens are deliberately excluded — exporting a credential defeats encrypting it at rest.

Delete account hard-deletes personal data and retains anonymized records with legal or audit significance: consent records, moderation decisions, abuse reports, revenue events, admin audit entries. Those are records about actions, not the user's personal data, and destroying them would break consent provenance and repeat-abuse tracking.

Owned organizations are handled explicitly — transferred to a remaining member, or removed if empty. Leaving that to a foreign key produced a constraint violation and a 500 on a user-facing flow; it is now covered by a test that builds an account with every blocking relation and deletes it.

Audit rows carry a denormalized actorLabel, so the log stays attributable after the account is gone. An audit log that loses its actor is not an audit log.