candle-annotator/openspec/changes/archive/2026-02-20-user-accounts/design.md
Marko Djordjevic 5fb9733bd6 Archive user-accounts change to openspec/changes/archive/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:50:36 +01:00

203 lines
10 KiB
Markdown

## Context
CandleAnnotator is a single-user full-stack app: Next.js 16 (App Router) + PostgreSQL (Drizzle ORM) + Python FastAPI ML service. All data is global — no users table, no auth, no data isolation. The entire app lives at `/` as a single page with 13 API routes.
We need to add multi-user support: registration, login, per-user data isolation. Lovable design mockups exist for landing, login, and register pages. The existing app workspace moves behind auth.
**Current stack**: Next.js 16, React 19, Drizzle ORM, PostgreSQL 16, Tailwind CSS, shadcn/ui, lightweight-charts, FastAPI (Python).
## Goals / Non-Goals
**Goals:**
- Email/password registration and login
- Google OAuth login
- Per-user data isolation (charts, annotations, models, settings)
- Landing page, login page, register page (from Lovable designs)
- User settings page (change password, display name, delete account)
- Auth middleware protecting app routes and API routes
- Smooth migration path for existing single-user data
**Non-Goals:**
- Email verification (defer — add later)
- Password reset via email (defer — requires email service)
- Admin panel / user management
- Role-based access control
- Rate limiting
- Two-factor authentication
- Social login beyond Google (GitHub, Discord, etc.)
- Team/organization accounts
- API key authentication for external integrations
## Decisions
### 1. Auth library: Auth.js v5 (NextAuth v5)
**Choice**: Auth.js v5 (`next-auth@5`) with JWT strategy
**Why**: Native App Router support, built-in Credentials + Google OAuth providers, `@auth/drizzle-adapter` exists for our ORM. JWT strategy avoids extra session table and keeps the ML service integration simple (pass user ID in headers).
**Alternatives considered**:
- **Lucia Auth**: Good but deprecated in favor of rolling your own. More manual setup.
- **Clerk/Auth0**: SaaS dependency, cost, overkill for this project.
- **Custom JWT**: More control but reinventing auth middleware, CSRF protection, etc.
**Session strategy**: JWT (not database sessions). User ID embedded in token. Stateless, no session table needed.
### 2. Password hashing: bcryptjs
**Choice**: `bcryptjs` (pure JS bcrypt implementation)
**Why**: No native dependencies (works in Docker without build tools), well-tested, sufficient for this use case.
**Alternatives considered**:
- **bcrypt** (native): Requires build tools in Docker, fails on some platforms.
- **argon2**: Better algorithm but requires native bindings. Overhead not justified here.
### 3. Database schema: `users` table + `user_id` FK on all tables
**Choice**: Add `users` table. Add `user_id` column (nullable initially for migration, then NOT NULL) to: `charts`, `annotations`, `annotation_types`, `span_annotations`, `span_label_types`.
**Users table**:
```
users
├── id: uuid (PK, default gen_random_uuid())
├── name: text (nullable)
├── email: text (unique, not null)
├── email_verified: timestamp (nullable)
├── password_hash: text (nullable — null for OAuth-only users)
├── image: text (nullable)
├── provider: text (default 'credentials') — 'credentials' | 'google'
├── provider_account_id: text (nullable) — Google sub ID
├── created_at: timestamp
├── updated_at: timestamp
```
**Why UUID for user IDs**: Avoids enumeration attacks, safe to expose in URLs/tokens, standard for auth systems.
**Why NOT use Drizzle adapter's default schema**: The `@auth/drizzle-adapter` creates `users`, `accounts`, `sessions`, `verification_tokens` tables. We'll use a simplified custom schema since we're using JWT (no sessions table) and deferring email verification. We'll handle the adapter mapping manually.
**Alternatives considered**:
- **Drizzle adapter default schema**: Creates 4 tables, adds complexity for features we don't need yet (verification tokens, separate accounts table). We can adopt it later if we add email verification or more OAuth providers.
- **Serial integer IDs**: Enumerable, less secure for user-facing IDs.
### 4. Routing structure
**Choice**:
```
/ → Landing page (public)
/login → Login page (public)
/register → Register page (public)
/app → Main workspace (protected, current page.tsx moves here)
/app/settings → User settings (protected)
/api/auth/* → NextAuth API routes
/api/* → Existing API routes (protected)
```
**Why**: Clean separation of public marketing pages and protected app. `/app` prefix makes middleware rules simple. Existing API routes stay at `/api/*` — just add auth check.
**Alternatives considered**:
- **Keep app at `/` and use `/landing`**: Unconventional, confuses SEO.
- **Subdomain (`app.candleannotator.com`)**: Adds deployment complexity.
### 5. Auth middleware approach
**Choice**: Next.js `proxy.ts` at project root. Uses Auth.js `auth()` to check session. Redirects:
- Unauthenticated users hitting `/app/*` or `/api/*` (except `/api/auth/*` and `/api/health`) → redirect to `/login`
- Authenticated users hitting `/login` or `/register` → redirect to `/app`
**Why**: Single enforcement point, runs on edge before page/API renders. Clean and standard Next.js pattern.
### 6. API route auth pattern
**Choice**: Helper function `getAuthUser(request)` that extracts user from JWT session. Every API route calls this at the top:
```typescript
const user = await getAuthUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// All queries filter by user.id
```
All Drizzle queries add `.where(eq(table.user_id, user.id))` for reads, and set `user_id: user.id` on inserts.
**Why**: Simple, explicit, no magic. Every route clearly shows it's user-scoped.
### 7. ML Service user context
**Choice**: Pass `X-User-ID` header from Next.js API routes to FastAPI ML service. The ML service trusts this header (internal network only, not exposed to clients).
**Why**: ML service doesn't need its own auth — it's internal. The Next.js layer validates auth and forwards user context. MLflow experiment names include user ID for isolation.
**Alternatives considered**:
- **JWT forwarding**: Requires Python JWT validation, adds complexity.
- **Separate user DB in Python**: Duplicates user management.
### 8. User data seeding on registration
**Choice**: On successful registration, seed default `annotation_types` and `span_label_types` for the new user (copy from a hardcoded default set).
**Why**: New users need at least the standard annotation types (break_up, break_down, line) and span label types to start working. Without seeding, the app would be empty/broken.
### 9. Frontend auth state
**Choice**: Use `next-auth/react` `SessionProvider` wrapping the app layout. Components use `useSession()` hook. Server components use `auth()` from `@/auth`.
**Why**: Standard Auth.js pattern. SessionProvider handles token refresh automatically.
### 10. Lovable design integration
**Choice**: Convert Lovable HTML mockups to React/Next.js components using existing Tailwind + shadcn/ui patterns. Extract the structure and classes, adapt to Next.js `<Link>`, form actions, etc. Keep the visual design but rewrite as proper React.
**Why**: Lovable generates static HTML with Tailwind. Direct reuse of CSS classes is possible, but the HTML structure needs to become React components with proper event handling.
### 11. Settings page features
**Choice**: `/app/settings` page with sections:
- **Profile**: Display name, email (read-only for OAuth users)
- **Security**: Change password (credentials users only)
- **Danger zone**: Delete account (with confirmation dialog)
**Why**: Covers essential user management needs. Change password is table stakes. Delete account is GDPR-friendly. Keep it minimal — add more settings later as needed.
## Risks / Trade-offs
**[Data migration for existing deployments]** → Create a migration script that assigns all existing data to a default "admin" user. Document in DEPLOYMENT.md. The migration adds `user_id` as nullable first, runs the script, then makes it NOT NULL.
**[OAuth users can't set password]** → If a user registers via Google, they have no password. The settings page should show "Signed in via Google" instead of password change form. If they want password login later, they'd need a "set password" flow (defer to future).
**[JWT secret rotation]** → If `AUTH_SECRET` is changed, all existing sessions invalidate. Document this. Not a risk per se, but users will be logged out.
**[ML service trusts X-User-ID header]** → The ML service is only accessible internally (bound to 127.0.0.1 in docker-compose). If it were exposed publicly, this would be a security hole. Document that ML service must never be publicly accessible.
**[No email verification initially]** → Users can register with any email. This means no password reset via email. Acceptable for initial release — add email verification later.
**[Unique constraint on annotation_types.name becomes per-user]** → Currently `annotation_types.name` has a unique constraint. With multi-user, the same name can exist for different users. Change to unique constraint on `(user_id, name)`. Same for `span_label_types.name`.
## Migration Plan
### Database migration steps:
1. Add `users` table
2. Add `user_id` column (nullable) to: `charts`, `annotation_types`, `span_label_types`, `annotations`, `span_annotations`
3. Create a default admin user
4. UPDATE all existing rows to set `user_id = admin_user_id`
5. ALTER columns to NOT NULL
6. Add foreign key constraints
7. Drop old unique constraints, add new composite unique constraints (e.g., `(user_id, name)` for annotation_types)
8. Add indexes on `user_id` columns for query performance
### Deployment steps:
1. Update `.env` with new variables: `AUTH_SECRET`, `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`
2. Run database migrations
3. Run data migration script (assign existing data to admin user)
4. Deploy new app version
5. First login as admin user to verify data
### Rollback strategy:
- Keep `user_id` nullable during testing phase
- If rollback needed: revert code, `user_id` columns remain but are ignored
- No data loss on rollback
## Open Questions
1. **Default admin credentials**: What email/password should the default admin user have for migrating existing data? Suggest: configurable via env var `DEFAULT_ADMIN_EMAIL` / `DEFAULT_ADMIN_PASSWORD`.
2. **Google OAuth project**: Do we have a Google Cloud project with OAuth credentials ready, or should we document setup steps?
3. **ML model isolation**: Should trained models be fully isolated per user (separate MLflow experiments), or is a shared model registry acceptable initially?