# Accounts and the control dashboard

Added after the original brief, which ruled out "complex auth" and said
"no accounts, no sync". That constraint is deliberately reversed here, on
request. Recording the reversal because the original reasoning was sound
and the new requirement has to earn its cost:

> Saved is device-local, no account. Storing reading state server-side
> would mean building auth, which the brief rules out.

The justification for changing it is **preference portability**: a reader
who sets filters on their phone should not set them again on the web, and
alert preferences (category / company / model) already exist in three
places — `subscribers`, `devices`, and browser storage — with no way to
recognise that they belong to the same person.

So accounts exist to **carry preferences**, not to gate content. Nothing
on the public site will ever sit behind a login.

---

## 1. What this does and does not add

**Adds**

- Email + password registration and login
- Social login (Google, GitHub, Apple)
- One place for a reader's preferences, shared across web and app
- Linking an existing email subscription or device to an account
- An owner dashboard for the site and app

**Does not add**

- Any paywall, or any content behind a login
- Comments, profiles, avatars, follows
- Roles beyond `user` and `owner`
- Password reset by security question, SMS, or anything needing a vendor
  beyond the email sender already configured

**Reading anonymously stays a first-class path.** An account is an
upgrade, never a gate. The homepage must never show a login wall, and the
app must be fully usable without signing in — that is the product's whole
posture, and an account prompt on first launch would contradict it.

---

## 2. Why not a hosted auth vendor

Auth0, Clerk, Supabase Auth and Firebase Auth all solve this faster. All
were rejected for the same reason the rest of the stack was chosen:

- **Cost.** Every one has a free tier that ends. The product's headline
  constraint is under $10/month; an auth bill that scales with signups
  is exactly the runaway the brief forbids.
- **Stack lock.** The brief pins NestJS + MySQL + Next.js and explicitly
  excludes Supabase. Adding a hosted identity provider re-introduces the
  vendor coupling that exclusion was meant to avoid.
- **It is not much code.** Password hashing, a signed session cookie, and
  three OAuth callbacks are well-understood. The risk is in getting the
  details right, not in the volume.

The one thing a vendor would genuinely buy is **not having to be careful
about password storage**. That is handled by using argon2id with sane
parameters and never inventing anything.

---

## 3. Session model

**Signed, httpOnly session cookie. Not JWT-in-localStorage.**

| | Cookie session | JWT in localStorage |
|---|---|---|
| XSS exposure | httpOnly: script cannot read it | Readable by any injected script |
| Revocation | Delete the row, session dies now | Valid until it expires |
| CSRF | Needs SameSite + same-origin checks | Not applicable |
| Works with SSR | Sent automatically on the server fetch | Needs manual plumbing |

The site is server-rendered and SEO-first, so the cookie also happens to
be the only option that works without turning pages into client
components. Revocation is what decides it though: "log out everywhere"
has to actually work.

- Cookie name `ar_session`, `httpOnly`, `Secure` in production,
  `SameSite=Lax`, `Path=/`.
- The value is a random 32-byte token, stored **hashed** in `sessions`.
  A leaked database does not hand over live sessions.
- 30 days, refreshed on use. Long because the product is a daily habit
  and re-authenticating a reader every week is friction with no security
  benefit for content that is public anyway.
- `SameSite=Lax` rather than `Strict`: `Strict` would drop the session on
  arrival from a shared link, which is the main way people reach the
  site.

**The mobile app cannot use cookies cleanly**, so it gets the same token
in the response body and sends it as `Authorization: Bearer`. Same row in
the same table, same revocation — only the transport differs. This is the
one place where "just use cookies everywhere" does not survive contact
with React Native.

---

## 4. Passwords

- **argon2id**, memory 19 MiB, time 2, parallelism 1 — the OWASP
  minimum. Not bcrypt: bcrypt silently truncates at 72 bytes and has no
  memory hardness.
- Minimum 10 characters. No composition rules (no "must contain a
  symbol") — they push people toward `Password1!` and are worse than
  length.
- Checked against a small list of the most common passwords. Not a full
  breach corpus, which would be megabytes for marginal benefit at this
  scale.
- Never logged, never returned, never in an error message.

**Timing.** Login compares in constant time and runs the hash even when
the email does not exist, so response time does not reveal whether an
address is registered.

**Enumeration.** Registration with an existing address returns the same
response as a fresh one, and sends an email saying "someone tried to
register with your address" instead. Anything else turns the signup form
into a membership oracle.

---

## 5. Social login

Google, GitHub, Apple. Chosen because they cover the audience: Google for
general readers, GitHub because this audience is technical, Apple because
iOS review requires it once any other social login is offered.

**Raw OAuth 2.0 + OIDC, no passport.js.** Three providers with nearly
identical authorization-code flows is less code than the passport
strategy plumbing, and passport's ecosystem is a large dependency surface
for something this contained.

The flow, per provider:

1. `GET /auth/oauth/:provider` — generate `state` and PKCE `code_verifier`,
   store both in a short-lived httpOnly cookie, redirect.
2. Provider redirects back to `/auth/oauth/:provider/callback`.
3. Verify `state` matches the cookie. **A missing or mismatched state is
   rejected outright** — this is the CSRF defence, not a formality.
4. Exchange the code (with `code_verifier`) for tokens, server-side.
5. Read the verified email and the provider's stable user id.

**Account linking is by verified email, and only by verified email.** If
Google says the address is verified and a local account already has it,
they are the same person. If the provider does *not* mark it verified,
the link is refused — otherwise anyone who can get a provider to accept
an unverified address could take over an account.

`oauth_accounts` is a separate table keyed on (provider, provider_user_id),
so one account can carry several logins and unlinking one does not
destroy the account.

---

## 6. Preferences: one shape, three sources

Today the same three filters exist in `subscribers` (email/Telegram),
`devices` (push), and browser storage. Accounts do not replace those —
they become the **canonical copy** that the others sync from.

```
accounts
  └── user_preferences   locale, theme, filters, thresholds, channels
  └── saved_posts        what Saved syncs to
  └── sessions           active logins, revocable
  └── oauth_accounts     linked social identities
  ├── subscribers.account_id   (nullable — anonymous subscribers keep working)
  └── devices.account_id       (nullable — anonymous devices keep working)
```

**Every existing row keeps working unlinked.** `account_id` is nullable
everywhere. Someone who subscribed by Telegram and never makes an account
must not notice any of this happened.

**Conflict rule on link:** the account's preferences win, and the device
or subscriber row is updated to match. The alternative — merging —
produces filters nobody chose. The reader is told what changed rather
than it happening silently.

---

## 7. Owner dashboard

The existing admin (`/admin`, one password, HMAC cookie) already covers
posts, rejections, sources, runs, costs and ad slots. That is genuinely
most of a control panel and is **not** being rebuilt.

What accounts add is people, so the dashboard gains:

- **Audience** — accounts, subscribers, devices; growth over time; which
  filters people actually choose
- **Account detail** — preferences, linked logins, sessions, with the
  ability to revoke a session or delete an account (GDPR erasure)
- **Channel health** — deliverability per channel, revoked device tokens,
  Telegram blocks

**The owner login stays separate from user accounts.** Promoting a normal
account to owner would mean one compromised social login is a full
takeover of the pipeline and the spend controls. The owner password is
environment configuration, not a database row, and there is exactly one.

An `accounts.role` column exists (`user` / `owner`) for a future second
operator, but the dashboard does not read it yet — the env password
remains the only way in.

---

## 8. What can go wrong, and what stops it

| Risk | Mitigation |
|---|---|
| Credential stuffing | Rate limit per IP and per account; lockout after repeated failures on one account, not one IP |
| Session theft via XSS | httpOnly cookie; the token is never in JS-readable storage on the web |
| CSRF on state-changing routes | `SameSite=Lax` + origin check on POST |
| OAuth code interception | PKCE on every provider, `state` verified, exact redirect URI |
| Account takeover via unverified email | Links refused unless the provider marks the email verified |
| Enumeration via signup or login | Identical responses and timing whether or not the address exists |
| Password reset token reuse | Single-use, 30 minutes, hashed at rest, invalidated on use and on password change |
| Leaked database | Passwords argon2id; session and reset tokens stored hashed |
| Owner takeover through a user account | Owner auth is env config, entirely separate from `accounts` |

---

## 9. Build order

- ✅ **8a** — schema, argon2 password auth, sessions, `/auth/*` endpoints
- ✅ **8b** — OAuth for Google, GitHub, Apple; account linking
- ✅ **8c** — preference sync; link existing subscribers and devices
- ✅ **8d** — web UI: register, login, account settings
- ✅ **8e** — app UI: sign in, with the Bearer transport
- ✅ **8f** — dashboard: audience and channel health

All built and verified against a live database. Still needs credentials
from the owner before any of it is usable in production — see below.

Each step ends with the site still fully usable signed out. If that ever
stops being true, the step is wrong.

---

## 10. Open questions for review

None of these block the build; each has a defensible default already
chosen, and is flagged for confirmation:

1. **Is email/password wanted at all**, or social-only? Social-only
   removes password storage, reset flows and credential stuffing
   entirely. Default taken: both, because GitHub/Google/Apple all exclude
   someone.
2. **Should an account be required to receive alerts?** Default taken:
   no. Telegram and email signup stay anonymous, because requiring an
   account would cut signups for no gain.
3. **Deletion policy.** Default taken: deleting an account anonymises the
   linked subscriber/device rows rather than deleting them, so someone
   who deletes their account does not silently lose their Telegram
   digest. This is arguable and may be wrong.
4. **Apple sign-in needs a paid Apple Developer account** ($99/year) to
   configure. It is built but stays disabled until that exists, and the
   button hides itself when unconfigured.

---

## 11. What you need to provide

None of this is usable in production until these exist. Everything is
built and tested; these are credentials and configuration only.

| Needed | For | Where |
|---|---|---|
| Google OAuth client id + secret | "Continue with Google" | console.cloud.google.com/apis/credentials |
| GitHub OAuth client id + secret | "Continue with GitHub" | github.com/settings/developers |
| Apple client id + secret | "Continue with Apple" — **needs a paid developer account** | developer.apple.com |
| A working email sender | Verification and password-reset links | Already configured via Resend, but sending is untested |

Redirect URI for every provider is
`<API_PUBLIC_URL>/auth/oauth/<provider>/callback`. Locally that is
`http://localhost:4000/api/auth/oauth/google/callback`.

Providers that are not configured hide their own buttons, so the site
works today with email and password alone.

**Email verification and password reset now send.** Verified end to end
against the live database with the console provider: tokens are issued,
stored hashed, single-use, and expire when they say they do. The `/verify`
and `/reset` pages exist in both languages.

They will only reach a real inbox once `RESEND_API_KEY` and `EMAIL_FROM`
are set and the sending domain is verified. Until then every message is
rendered and logged rather than sent, which is visible in the API output.
