From 1a15b08641d9dedddbdaad0108b8db87a93b9e72 Mon Sep 17 00:00:00 2001 From: leecampbell-codeagent Date: Sat, 7 Mar 2026 06:58:55 +0000 Subject: [PATCH 1/5] feat(account): authentication and user management with Clerk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - User entity (create/reconstitute), Role/KycStatus constants, 6 domain errors - UserRepository port, UserRepositoryPg adapter (parameterised upsert) - AuthPort interface with ClerkAuthAdapter + MockAuthAdapter (MOCK_AUTH=true) - GetOrCreateUserService (lazy upsert on first /v1/me call) - UpdateUserProfileService, AssignRolesService (admin-only) - GET /v1/me, PATCH /v1/me endpoints — clerk_id never in responses - 39 backend tests passing (domain, application, API layers) Frontend: - ClerkProvider in main.tsx with VITE_CLERK_PUBLISHABLE_KEY guard - AuthPageLayout, AuthLoadingScreen, ProtectedRoute components - SignInPage, SignUpPage with MMF Clerk appearance config - DashboardPage placeholder (protected route) - createApiClient with JWT Bearer injection - useCurrentUser TanStack Query hook - 7 frontend tests passing Infrastructure: - db/migrations/20260307120000_create_users.sql: users table with roles[], kyc_status - Migration applied to local dev DB Co-Authored-By: Claude Sonnet 4.6 --- .claude/context/gotchas.md | 8 + .claude/context/patterns.md | 22 +- .claude/manual-tasks.md | 5 + .claude/mock-status.md | 2 +- .claude/prds/feat-002-design.md | 438 +++++++ .claude/prds/feat-002-research.md | 250 ++++ .claude/prds/feat-002-spec.md | 1156 +++++++++++++++++ .claude/prds/feat-002-validation.md | 82 ++ .env.example | 1 + db/migrations/20260307120000_create_users.sql | 32 + package-lock.json | 53 +- .../src/account/adapters/UserRepositoryPg.ts | 113 ++ .../src/account/api/account.router.test.ts | 289 +++++ .../backend/src/account/api/account.router.ts | 246 ++++ .../src/account/api/account.schemas.ts | 15 + .../application/AssignRolesService.test.ts | 107 ++ .../account/application/AssignRolesService.ts | 35 + .../GetOrCreateUserService.test.ts | 75 ++ .../application/GetOrCreateUserService.ts | 18 + .../application/UpdateUserProfileService.ts | 16 + .../backend/src/account/domain/KycStatus.ts | 10 + packages/backend/src/account/domain/Role.ts | 13 + .../backend/src/account/domain/User.test.ts | 170 +++ packages/backend/src/account/domain/User.ts | 96 ++ .../account/domain/errors/InvalidRoleError.ts | 9 + .../errors/RoleAssignmentForbiddenError.ts | 9 + .../SuperAdminAssignmentRestrictedError.ts | 9 + .../domain/errors/UserAlreadyExistsError.ts | 9 + .../domain/errors/UserNotFoundError.ts | 9 + .../domain/errors/UserValidationError.ts | 5 + .../src/account/ports/UserRepository.ts | 12 + packages/backend/src/server.ts | 32 + .../shared/adapters/auth/ClerkAuthAdapter.ts | 19 + .../shared/adapters/auth/MockAuthAdapter.ts | 22 + packages/backend/src/shared/domain/Result.ts | 28 + packages/backend/src/shared/ports/AuthPort.ts | 24 + packages/frontend/package.json | 2 +- packages/frontend/src/App.test.tsx | 14 +- packages/frontend/src/App.tsx | 44 +- packages/frontend/src/api/client.ts | 51 + .../src/components/auth/AuthLoadingScreen.tsx | 45 + .../src/components/auth/AuthPageLayout.tsx | 66 + .../components/auth/ProtectedRoute.test.tsx | 82 ++ .../src/components/auth/ProtectedRoute.tsx | 22 + .../frontend/src/hooks/useCurrentUser.test.ts | 106 ++ packages/frontend/src/hooks/useCurrentUser.ts | 26 + packages/frontend/src/lib/clerkAppearance.ts | 106 ++ packages/frontend/src/main.tsx | 10 +- packages/frontend/src/pages/DashboardPage.tsx | 26 + packages/frontend/src/pages/SignInPage.tsx | 17 + packages/frontend/src/pages/SignUpPage.tsx | 17 + packages/frontend/src/styles/auth.css | 49 + packages/frontend/src/vite-env.d.ts | 1 + packages/frontend/vitest.config.ts | 7 + 54 files changed, 4106 insertions(+), 24 deletions(-) create mode 100644 .claude/prds/feat-002-design.md create mode 100644 .claude/prds/feat-002-research.md create mode 100644 .claude/prds/feat-002-spec.md create mode 100644 .claude/prds/feat-002-validation.md create mode 100644 db/migrations/20260307120000_create_users.sql create mode 100644 packages/backend/src/account/adapters/UserRepositoryPg.ts create mode 100644 packages/backend/src/account/api/account.router.test.ts create mode 100644 packages/backend/src/account/api/account.router.ts create mode 100644 packages/backend/src/account/api/account.schemas.ts create mode 100644 packages/backend/src/account/application/AssignRolesService.test.ts create mode 100644 packages/backend/src/account/application/AssignRolesService.ts create mode 100644 packages/backend/src/account/application/GetOrCreateUserService.test.ts create mode 100644 packages/backend/src/account/application/GetOrCreateUserService.ts create mode 100644 packages/backend/src/account/application/UpdateUserProfileService.ts create mode 100644 packages/backend/src/account/domain/KycStatus.ts create mode 100644 packages/backend/src/account/domain/Role.ts create mode 100644 packages/backend/src/account/domain/User.test.ts create mode 100644 packages/backend/src/account/domain/User.ts create mode 100644 packages/backend/src/account/domain/errors/InvalidRoleError.ts create mode 100644 packages/backend/src/account/domain/errors/RoleAssignmentForbiddenError.ts create mode 100644 packages/backend/src/account/domain/errors/SuperAdminAssignmentRestrictedError.ts create mode 100644 packages/backend/src/account/domain/errors/UserAlreadyExistsError.ts create mode 100644 packages/backend/src/account/domain/errors/UserNotFoundError.ts create mode 100644 packages/backend/src/account/domain/errors/UserValidationError.ts create mode 100644 packages/backend/src/account/ports/UserRepository.ts create mode 100644 packages/backend/src/shared/adapters/auth/ClerkAuthAdapter.ts create mode 100644 packages/backend/src/shared/adapters/auth/MockAuthAdapter.ts create mode 100644 packages/backend/src/shared/domain/Result.ts create mode 100644 packages/backend/src/shared/ports/AuthPort.ts create mode 100644 packages/frontend/src/api/client.ts create mode 100644 packages/frontend/src/components/auth/AuthLoadingScreen.tsx create mode 100644 packages/frontend/src/components/auth/AuthPageLayout.tsx create mode 100644 packages/frontend/src/components/auth/ProtectedRoute.test.tsx create mode 100644 packages/frontend/src/components/auth/ProtectedRoute.tsx create mode 100644 packages/frontend/src/hooks/useCurrentUser.test.ts create mode 100644 packages/frontend/src/hooks/useCurrentUser.ts create mode 100644 packages/frontend/src/lib/clerkAppearance.ts create mode 100644 packages/frontend/src/pages/DashboardPage.tsx create mode 100644 packages/frontend/src/pages/SignInPage.tsx create mode 100644 packages/frontend/src/pages/SignUpPage.tsx create mode 100644 packages/frontend/src/styles/auth.css create mode 100644 packages/frontend/src/vite-env.d.ts diff --git a/.claude/context/gotchas.md b/.claude/context/gotchas.md index e027eaf..a9aa363 100644 --- a/.claude/context/gotchas.md +++ b/.claude/context/gotchas.md @@ -11,8 +11,16 @@ ## Frontend - **Vite version conflict in npm workspaces:** If the root `node_modules` has a different vite version than a package's `node_modules` (e.g., vite 7 at root from hoisting, vite 6 in frontend), TypeScript will report type mismatches for `@tailwindcss/vite` and `@vitejs/plugin-react` plugins in `vite.config.ts`. Fix by aligning the frontend's `vite` version to match the hoisted root version (e.g., `"vite": "^7.0.0"`). +- **`@clerk/react` v5.54.0 is broken:** The only `@clerk/react` v5 release is v5.54.0, which references `loadClerkUiScript` from `@clerk/shared` that doesn't exist in any v3.x. Use `@clerk/react@^6.0.0` instead. npm will install a nested `@clerk/react/node_modules/@clerk/shared@^4.0.0` to satisfy the dependency. +- **Vitest OOM when loading react-router or @clerk/react in isolation:** Running a test file in its own vitest worker that imports `react-router` or the real `@clerk/react` module graph causes JS heap OOM at 4GB in this environment. Fix: (1) use `pool: 'threads'` with `singleThread: true, isolate: false` in vitest config so all tests share one worker/module cache, and (2) in individual test files, mock heavy deps (`react-router`, `@clerk/react`) in the `vi.mock` factory to prevent loading the real modules. Also add explicit `afterEach(cleanup)` when using `isolate: false`. +- **`vite-env.d.ts` is required:** The frontend `tsconfig.app.json` does not include `vite/client` types by default. Without `src/vite-env.d.ts` containing `/// `, TypeScript will report `Property 'env' does not exist on type 'ImportMeta'` for `import.meta.env` usage. - **`!important` in CSS triggers Biome warnings:** The `noImportantStyles` rule from Biome's recommended ruleset flags all `!important` usage. The `prefers-reduced-motion` safety net overrides in `tokens.css` are intentional and produce warnings (not errors). These are acceptable — do not remove them. +- **Express 5 `req.params` types:** In Express 5 with `@types/express` v5, `req.params[key]` is typed as `string | string[] | undefined`. When you need a guaranteed string, cast: `req.params.id as string`. Avoid bracket notation like `req.params['id']` — Biome flags it with `useLiteralKeys`. +- **Biome `noUselessConstructor`:** A constructor that only calls `super(message)` with an identical signature to the parent is flagged as useless and should be removed. TypeScript will inherit the parent constructor automatically. This applies to domain error subclasses that pass a fixed message to `DomainError`. +- **`UserValidationError` has no constructor:** Unlike errors with fixed messages, `UserValidationError` accepts a dynamic message. After removing the useless constructor, callers pass the message directly to the inherited `DomainError(message)` constructor — TypeScript allows this because the parent constructor is public. +- **MockAuthAdapter `getAuthContext` never returns null:** The mock adapter's `getAuthContext` always returns an `AuthContext` (not `null`). Test code that relies on getting a 401 from lack of auth should use real Clerk adapter or test the router's own null-guard code path directly. + ## Infrastructure +- **Router factory with DI:** Routers are created via `createXxxRouter(deps)` factory functions that accept injected dependencies (authAdapter, repos, services). This enables supertest testing without touching real infrastructure. +- **Error response format:** `{ error: { code: string, message: string, correlation_id: string } }`. Domain errors map to HTTP statuses via a lookup table in the router. Unhandled errors return 500 with code `INTERNAL_SERVER_ERROR`. +- **Auth selection pattern:** `server.ts` selects `MockAuthAdapter` when `MOCK_AUTH=true`, else `ClerkAuthAdapter`. Both implement `AuthPort`. The global middleware populates auth context; `requireAuthMiddleware` is applied only to the `/v1` router group. +- **Protected route group:** `v1Router.use(authAdapter.requireAuthMiddleware())` applied to the express sub-router, then `app.use('/v1', v1Router)`. Health check mounted on `app` before the v1 group. +- **Correlation IDs:** Extracted from `x-correlation-id` header or generated via `crypto.randomUUID()` per request. Included in all error responses. +- **User serialization:** `serializeUser(user)` helper in the router omits `clerkId` — `clerk_id` never appears in API responses. +- **Result type:** `Result` in `packages/backend/src/shared/domain/Result.ts` — `.isSuccess`, `.isFailure`, `.value`, `.error`. Used by domain factory methods to avoid throwing. + +## Domain Patterns (continued) + +- **Result type pattern:** Domain factory methods (`User.create()`) return `Result` — never throw. Callers check `.isFailure` and throw `.error` at the application service boundary if needed. +- **Entity factory duality:** `Entity.create(props)` validates inputs and returns `Result`. `Entity.reconstitute(props)` skips validation — used only for DB hydration in repository adapters. +- **Mock repository in tests:** Build a plain object implementing the port interface with `vi.fn()` mocks. Override individual methods per test case. No class needed. ## Testing Patterns - **Backend Vitest config:** `vitest.config.ts` uses `environment: 'node'`, `globals: true`, includes `src/**/*.test.ts`. Coverage excludes `src/server.ts` and test files themselves. - **CommonJS backend with tsx dev runner:** Backend uses `"module": "CommonJS"` + `"moduleResolution": "node10"` in tsconfig for `tsc` builds. `tsx watch src/server.ts` is used in dev — tsx handles both module systems seamlessly in dev mode. - **Tailwind CSS v4 import syntax:** Use `@import "tailwindcss"` in the CSS entry file (not `@tailwind base/components/utilities`). The `@tailwindcss/vite` plugin processes it — no `tailwind.config.*` file needed. -- **Frontend Vitest config:** `vitest.config.ts` uses `environment: 'jsdom'`, `globals: true`, `setupFiles: ['./src/test/setup.ts']`, includes `src/**/*.test.tsx` and `src/**/*.test.ts`. Setup file imports `@testing-library/jest-dom`. +- **Frontend Vitest config:** `vitest.config.ts` uses `environment: 'jsdom'`, `globals: true`, `setupFiles: ['./src/test/setup.ts']`, includes `src/**/*.test.tsx` and `src/**/*.test.ts`. Setup file imports `@testing-library/jest-dom`. Pool set to `threads` with `singleThread: true, isolate: false` to prevent OOM when loading heavy deps (Clerk/react-router) in separate workers. +- **Clerk appearance config:** `packages/frontend/src/lib/clerkAppearance.ts` exports `clerkAppearance: Appearance`. Uses raw hex values (not CSS custom properties) because Clerk's appearance prop injects inline styles. Both `SignIn` and `SignUp` share this config. Import from `@clerk/types` for the type. +- **API client factory:** `packages/frontend/src/api/client.ts` exports `createApiClient(getToken)` returning `{ get, patch, post }`. Takes a `getToken: () => Promise` function from Clerk's `useAuth`. Throws `ApiError` with status, code, and message on non-2xx responses. - **CSS design tokens:** Two-tier token architecture in `packages/frontend/src/styles/tokens.css`. Tier 1 identity tokens (raw values) and Tier 2 semantic tokens (component consumption). Component code consumes only Tier 2 tokens. Imported via `global.css` which is the CSS entry point in `main.tsx`. - **Frontend tsconfig composite pattern:** Root `tsconfig.json` has `"files": []` and project references to `tsconfig.app.json` (src files, `jsx: react-jsx`, `noEmit: true`, `composite: true`) and `tsconfig.node.json` (vite/vitest configs). Use `tsc -b --noEmit` for typecheck. - **Semantic elements over ARIA roles:** Biome's `useSemanticElements` rule requires using native semantic HTML elements instead of ARIA roles on generic elements. Use `` (which has `role="status"` natively) instead of `
`. diff --git a/.claude/manual-tasks.md b/.claude/manual-tasks.md index bed4b5d..21e4a3e 100644 --- a/.claude/manual-tasks.md +++ b/.claude/manual-tasks.md @@ -25,6 +25,11 @@ Or use the GitHub UI to open a PR from `ralph/feat-001-monorepo-scaffold` → `m - `POSTHOG_API_KEY` — PostHog project settings > Project API Key - All mock adapter flags default to `true` for local dev — no action needed +### MANUAL-004: Re-run migrations after merging feat-002 +**Feature:** feat-002 +**Action:** After merging feat-002 to main, run: `dbmate --url "$DATABASE_URL" up` +This applies the `create_users` migration. + ### MANUAL-002: Start PostgreSQL **Feature:** feat-001 **Action:** Start the PostgreSQL container: `docker-compose up -d postgres` diff --git a/.claude/mock-status.md b/.claude/mock-status.md index b2104ab..58ab50c 100644 --- a/.claude/mock-status.md +++ b/.claude/mock-status.md @@ -4,7 +4,7 @@ | Service | Adapter | Status | Feature | Notes | |---------|---------|--------|---------|-------| -| Clerk | Auth | Mock available via `MOCK_AUTH=true` | feat-002 | Not yet implemented | +| Clerk | Auth | Real (with MOCK_AUTH=true fallback for tests) | feat-002 | ClerkAuthAdapter + MockAuthAdapter implemented | | Stripe | Payments | Mock (stub) | feat-010 | Not yet implemented | | Veriff | KYC | Mock (auto-approve) | feat-007 | Not yet implemented | | AWS SES | Email | Mock (log only) | feat-014 | Not yet implemented | diff --git a/.claude/prds/feat-002-design.md b/.claude/prds/feat-002-design.md new file mode 100644 index 0000000..c8261e5 --- /dev/null +++ b/.claude/prds/feat-002-design.md @@ -0,0 +1,438 @@ +# feat-002: Authentication — Design Spec + +**Feature ID:** feat-002 +**Spec Type:** Design +**Status:** Ready for Implementation +**Date:** 2026-03-07 +**Depends On:** feat-002-spec.md (technical spec), L2-001 (Brand Application Standard), L3-005 (Frontend Standards) + +--- + +## 1. Overview + +Authentication UI for Mars Mission Fund is delivered primarily through Clerk's hosted `` and `` components. The MMF brand is applied to these components via Clerk's `appearance` prop — this is a token-mapping exercise, not a full custom UI build. The total custom UI work is: + +- A full-viewport wrapper page for Sign In (`/sign-in`) +- A full-viewport wrapper page for Sign Up (`/sign-up`) +- A loading state while Clerk initialises +- A `ProtectedRoute` component that redirects unauthenticated users + +Clerk manages all form rendering, validation, error messaging, multi-step flows (email OTP, factor-two, etc.), and SSO. We do not re-implement any of that. + +**Design principle:** The auth pages are the first thing a new user sees. They must look and feel unmistakably MMF — deep space foundation, launch-fire energy, Bebas Neue authority — while Clerk handles the functional complexity. + +--- + +## 2. Clerk Appearance Configuration + +Both `` and `` receive the same `appearance` prop object. Define it once and share it. + +### 2.1 Appearance Object + +```typescript +import type { Appearance } from '@clerk/types'; + +export const clerkAppearance: Appearance = { + variables: { + colorPrimary: '#FF5C1A', // --launchfire (--color-action-primary) + colorBackground: '#0B1628', // --deep-space (--color-bg-surface) + colorText: '#E8EDF5', // --chrome (--color-text-primary) + colorInputBackground: 'rgba(245, 248, 255, 0.04)', // --white/4% (--color-bg-input) + colorInputText: '#E8EDF5', // --chrome (--color-text-primary) + colorTextSecondary: '#C8D0DC', // --silver (--color-text-secondary) + colorTextOnPrimaryBackground: '#F5F8FF', // --white (--color-action-primary-text) + colorDanger: '#C1440E', // --red-planet (--color-status-error) + colorSuccess: '#2FE8A2', // --success (--color-status-success) + borderRadius: '12px', // --radius-md (--radius-input) + fontFamily: '"DM Sans", sans-serif', // --font-body + fontFamilyButtons: '"DM Sans", sans-serif', // --font-body + fontSize: '14px', + spacingUnit: '16px', + }, + elements: { + // Card container + card: { + background: '#0B1628', // --color-bg-surface + border: '1px solid rgba(245, 248, 255, 0.06)', // --color-border-subtle + borderRadius: '20px', // --radius-card (--radius-xl) + boxShadow: '0 24px 48px rgba(6, 10, 20, 0.5)', // deep shadow, void-based + padding: '40px', + }, + // Header title — "Sign in" / "Create your account" + headerTitle: { + fontFamily: '"Bebas Neue", sans-serif', // --font-display + fontSize: '40px', // --type-section-heading + fontWeight: '400', + letterSpacing: '0.04em', + textTransform: 'uppercase', + color: '#E8EDF5', // --color-text-primary + }, + // Header subtitle — "Welcome back!" etc. + headerSubtitle: { + fontFamily: '"DM Sans", sans-serif', // --font-body + fontSize: '16px', // --type-body + color: '#C8D0DC', // --color-text-secondary + }, + // Primary submit button + formButtonPrimary: { + background: 'linear-gradient(135deg, #FF5C1A, #FF8C42, #FFB347)', // --gradient-action-primary + color: '#F5F8FF', // --color-action-primary-text + fontFamily: '"DM Sans", sans-serif', // --font-body + fontSize: '14px', // --type-button + fontWeight: '600', + letterSpacing: '0.01em', + borderRadius: '100px', // --radius-button (--radius-full) + border: 'none', + boxShadow: '0 4px 16px rgba(255, 92, 26, 0.35)', // --color-action-primary-shadow + padding: '12px 24px', + transition: 'opacity 150ms ease-out', // --motion-hover + }, + // Footer action links — "Don't have an account? Sign up" + footerActionLink: { + color: '#FF8C42', // --ignition (--color-action-ghost-text) + fontWeight: '600', + }, + footerActionText: { + color: '#C8D0DC', // --color-text-secondary + }, + // Form input fields + formFieldInput: { + background: 'rgba(245, 248, 255, 0.04)', // --color-bg-input + border: '1px solid rgba(245, 248, 255, 0.10)', // --color-border-input + borderRadius: '12px', // --radius-input (--radius-md) + color: '#E8EDF5', // --color-text-primary + fontFamily: '"DM Sans", sans-serif', + fontSize: '14px', + }, + formFieldLabel: { + fontFamily: '"Space Mono", monospace', // --font-data + fontSize: '12px', // --type-input-label + fontWeight: '600', + letterSpacing: '0.05em', + textTransform: 'uppercase', + color: '#8A96A8', // --color-text-tertiary + }, + // Social / OAuth provider buttons + socialButtonsBlockButton: { + background: 'rgba(245, 248, 255, 0.06)', // --color-action-secondary-bg + border: '1px solid rgba(245, 248, 255, 0.12)', // --color-action-secondary-border + borderRadius: '100px', // --radius-button + color: '#C8D0DC', // --color-action-secondary-text + fontFamily: '"DM Sans", sans-serif', + fontSize: '14px', + fontWeight: '600', + }, + dividerLine: { + background: 'rgba(245, 248, 255, 0.06)', // --color-border-subtle + }, + dividerText: { + color: '#8A96A8', // --color-text-tertiary + fontFamily: '"DM Sans", sans-serif', + fontSize: '13px', // --type-body-small + }, + // "Secured by Clerk" footer + footer: { + display: 'none', // Hide Clerk branding in demo context + }, + }, +}; +``` + +### 2.2 Token Mapping Rationale + +The `clerkAppearance` object uses raw hex/rgba values because Clerk's `appearance` prop does not consume CSS custom properties — it injects inline styles. This is the only place in the codebase where Tier 1 identity token values are used directly. The semantic intent is documented inline in comments. + +This is a deliberate exception, not a violation. The mapping table below shows the intent: + +| Clerk Variable / Element | Raw Value Used | Semantic Token Equivalent | +|---|---|---| +| `colorPrimary` | `#FF5C1A` | `--color-action-primary` | +| `colorBackground` | `#0B1628` | `--color-bg-surface` | +| `colorText` | `#E8EDF5` | `--color-text-primary` | +| `colorInputBackground` | `rgba(245,248,255,0.04)` | `--color-bg-input` | +| `colorDanger` | `#C1440E` | `--color-status-error` | +| `colorSuccess` | `#2FE8A2` | `--color-status-success` | +| Card border | `rgba(245,248,255,0.06)` | `--color-border-subtle` | +| Card border-radius | `20px` | `--radius-card` | +| Button gradient | `135deg, #FF5C1A, #FF8C42, #FFB347` | `--gradient-action-primary` | +| Button border-radius | `100px` | `--radius-button` | +| Header font | Bebas Neue | `--font-display` | +| Body font | DM Sans | `--font-body` | +| Label font | Space Mono | `--font-data` | + +--- + +## 3. Page Layouts + +### 3.1 Sign In Page — `/sign-in` + +**File:** `packages/frontend/src/pages/SignInPage.tsx` + +``` +┌─────────────────────────────────────────────────────────┐ +│ [full viewport, --gradient-hero background] │ +│ │ +│ │ +│ ┌──────────────────────┐ │ +│ │ [coin icon mark] │ │ +│ │ MARS MISSION FUND │ <- wordmark │ +│ │ [subtitle] │ │ +│ ├──────────────────────┤ │ +│ │ │ │ +│ │ │ │ +│ │ [Clerk card] │ │ +│ │ │ │ +│ └──────────────────────┘ │ +│ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +**Layout rules:** + +- Background: `--gradient-hero` (`linear-gradient(135deg, #060A14 0%, #0B1628 50%, rgba(255,92,26,0.15) 100%)`) applied to the full viewport +- Content column: centered horizontally and vertically, `max-width: 480px`, `width: 100%`, padding `0 24px` +- Logo block sits above the Clerk card with `margin-bottom: 32px` +- No navigation bar, no header, no footer — auth pages are full-bleed standalone + +**Logo block:** + +- Coin icon mark: `` of the coin SVG at 64px height, `alt=""` (decorative — wordmark below carries the semantic name), `aria-hidden="true"` +- Wordmark heading: `MARS MISSION FUND` in `--font-display` (Bebas Neue), `--type-page-title` (56px), `--color-text-primary`, `letter-spacing: 0.04em`, `text-transform: uppercase`, `text-align: center` +- Subtitle: `"Fund the missions that get us there."` in `--font-body` (DM Sans), `--type-body` (16px), `--color-text-secondary`, `text-align: center`, `margin-top: 8px` +- Logo block heading should carry `aria-label="Mars Mission Fund"` on the coin image or an `

` wrapping the wordmark text — see accessibility notes below + +**Clerk component:** + +```tsx + +``` + +### 3.2 Sign Up Page — `/sign-up` + +Identical layout to Sign In. Swap the Clerk component: + +```tsx + +``` + +The subtitle copy changes to: `"Join the mission. Back the future."` — same token/size. + +### 3.3 Shared Auth Page Wrapper Component + +Extract the repeated wrapper into a shared component to avoid duplication: + +**File:** `packages/frontend/src/components/auth/AuthPageLayout.tsx` + +Props: +```typescript +interface AuthPageLayoutProps { + readonly subtitle: string; + readonly children: React.ReactNode; +} +``` + +The wrapper renders: +1. Full-viewport div with `--gradient-hero` background, `min-height: 100vh`, `display: flex`, `align-items: center`, `justify-content: center` +2. Inner content column (`max-width: 480px`) +3. Logo block (coin icon + MARS MISSION FUND heading + subtitle) +4. `{children}` (the Clerk component) + +--- + +## 4. Protected Route UX + +**File:** `packages/frontend/src/components/auth/ProtectedRoute.tsx` + +### 4.1 Unauthenticated Redirect + +When Clerk has finished loading (`isLoaded === true`) and the user is not signed in (`isSignedIn === false`): + +- Immediately render `` +- No flash of protected content — the check is synchronous once `isLoaded` is true +- `replace` is used so the browser back button does not return to the protected route + +### 4.2 Post-Sign-In Redirect + +Clerk's `fallbackRedirectUrl="/dashboard"` handles the common case. For redirecting back to the originally requested URL after sign-in, use Clerk's `afterSignInUrl` prop or the `redirectUrl` search parameter that Clerk appends automatically when it redirects to `/sign-in`. + +No custom redirect-URL logic is needed in feat-002 — Clerk handles this transparently. + +### 4.3 Loading State (while `isLoaded === false`) + +While Clerk is initialising, render the full-page loading state (see Section 5). Do not render a blank page or a partial layout. + +--- + +## 5. Loading State + +**File:** `packages/frontend/src/components/auth/AuthLoadingScreen.tsx` + +Shown while Clerk has not yet confirmed authentication status (`isLoaded === false` from `useAuth()`). + +### 5.1 Visual Specification + +``` +┌─────────────────────────────────────────────────────────┐ +│ [--color-bg-page background, full viewport] │ +│ │ +│ │ +│ ┌───────────────┐ │ +│ │ [coin icon] │ <- 48px, pulsing │ +│ └───────────────┘ │ +│ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +- Background: `--color-bg-page` (`#060A14` / `--void`) — deepest space, fills viewport +- Centered content: coin icon mark at 48px height +- Pulse animation on the coin icon (opacity 1 → 0.4 → 1, not scale — avoids layout shift) + +### 5.2 Animation Specification + +```css +@keyframes mmf-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.auth-loading-icon { + animation: mmf-pulse 1.5s ease-in-out infinite; + /* Uses --motion-urgency: 1.5s, ease-in-out, infinite */ +} + +@media (prefers-reduced-motion: reduce) { + .auth-loading-icon { + animation: none; + opacity: 0.7; /* static glow at reduced opacity per L2-001 Section 5.2 */ + } +} +``` + +The `1.5s ease-in-out infinite` timing aligns with `--motion-urgency` from L2-001 Section 2.9. + +### 5.3 Accessibility + +- The loading screen root element: `` — `` has implicit `role="status"` and avoids the Biome `useSemanticElements` lint rule that flags `
` (per feat-002-spec.md Section 14, Note 9) +- The coin icon: `` — decorative; the `aria-label` on the `` carries the accessible name + +--- + +## 6. Typography on Auth Pages + +All typography on auth page wrapper content (logo block, subtitle) follows the closed type scale from L2-001 Section 2.8. No custom sizes. + +| Element | Semantic Token | Font | Size | Weight | Additional | +|---|---|---|---|---|---| +| "MARS MISSION FUND" wordmark | `--type-page-title` | Bebas Neue (`--font-display`) | 56px | 400 | `letter-spacing: 0.04em`, `text-transform: uppercase` | +| Subtitle copy | `--type-body` | DM Sans (`--font-body`) | 16px | 400 | `line-height: 1.7`, `--color-text-secondary` | + +**Rule:** The wordmark on auth pages is an `

`. It is the only heading on the page and represents the application name for screen reader users. Clerk's own card header ("Sign in", "Create account") renders at a visually appropriate size via the `headerTitle` appearance override but is not an `

` — it is decorative titling within the Clerk component. + +--- + +## 7. Responsive Behaviour + +Auth pages use the mobile-first breakpoint system from L3-005 Section 5.2. + +| Viewport | Layout | +|---|---| +| < 480px (mobile) | Full-width card, `padding: 0 16px`, coin at 48px, wordmark at 40px (drops to `--type-section-heading`) | +| 480px – 768px | 480px max-width column, centred, standard sizing | +| > 768px | Same as 480px+ — layout does not expand further | + +The Clerk card width is constrained by its wrapper column. Clerk's own responsive behaviour handles input sizing inside the card. + +On mobile, the wordmark font size reduces from 56px (`--type-page-title`) to 40px (`--type-section-heading`) to avoid overflow. This is the only responsive type adjustment on auth pages. + +--- + +## 8. Colour and Visual Summary + +| Surface | Token | Resolved Value | +|---|---|---| +| Page background | `--gradient-hero` | `linear-gradient(135deg, #060A14 0%, #0B1628 50%, rgba(255,92,26,0.15) 100%)` | +| Loading screen background | `--color-bg-page` | `#060A14` | +| Clerk card background | `--color-bg-surface` | `#0B1628` | +| Clerk card border | `--color-border-subtle` | `rgba(245,248,255,0.06)` | +| Wordmark text | `--color-text-primary` | `#E8EDF5` | +| Subtitle text | `--color-text-secondary` | `#C8D0DC` | +| Primary button | `--gradient-action-primary` | `linear-gradient(135deg, #FF5C1A, #FF8C42, #FFB347)` | +| Footer links | `--color-action-ghost-text` | `#FF8C42` | +| Input background | `--color-bg-input` | `rgba(245,248,255,0.04)` | +| Input border | `--color-border-input` | `rgba(245,248,255,0.10)` | +| Input labels | `--color-text-tertiary` | `#8A96A8` | +| Error colour | `--color-status-error` | `#C1440E` | +| Success colour | `--color-status-success` | `#2FE8A2` | + +--- + +## 9. Logo Usage on Auth Pages + +Per L2-001 Section 6.1, the Login/Registration context uses the **full vertical lockup (dark variant)**: +- Coin icon mark at 64px height (120px per spec is for marketing contexts; 64px is appropriate for the card-above-clerk layout without dominating the viewport) +- Full wordmark below the icon + +The coin icon is the SVG asset from the brand guidelines. In the codebase, reference it from `packages/frontend/src/assets/logo/` (the asset path to be confirmed by the implementation agent when placing brand assets). + +--- + +## 10. Accessibility Checklist + +| Requirement | Implementation | +|---|---| +| Auth page has an `

` | "MARS MISSION FUND" wordmark is an `

` | +| Decorative images hidden | Coin icon: `alt=""`, `aria-hidden="true"` | +| Loading state announced | `` | +| Reduced motion respected | Loading pulse disables via `prefers-reduced-motion: reduce` | +| Focus on sign-in fields | Clerk manages focus within its component; wrapper does not interfere | +| Colour contrast | All wrapper text uses tokens verified at AAA in L2-001 Section 5.1 | +| Touch targets | Clerk manages its own touch targets; wrapper has no interactive elements below the Clerk card | +| Skip-to-content | Not required on auth pages — single-purpose pages with no navigation to skip | + +--- + +## 11. Not In Scope + +The following are explicitly excluded from feat-002 design work: + +- Profile settings page UI — deferred to feat-011 or later +- Role assignment UI — administrators use the API directly in the demo +- MFA setup or session management UI — Clerk handles transparently +- Onboarding flow UI — feat-003 +- Full KYC verification flow — feat-007 +- SSO provider linking UI — Clerk handles OAuth transparently +- Account deletion / GDPR erasure UI — P3 +- Profile picture upload UI — file upload and S3 integration deferred +- A public-facing landing page at `/` — the root redirects to `/home` which is a protected route placeholder; no marketing page design is required for feat-002 + +--- + +## 12. Files Produced by This Feature + +| File | Description | +|---|---| +| `packages/frontend/src/pages/SignInPage.tsx` | Sign In page wrapper | +| `packages/frontend/src/pages/SignUpPage.tsx` | Sign Up page wrapper | +| `packages/frontend/src/components/auth/AuthPageLayout.tsx` | Shared auth page layout wrapper | +| `packages/frontend/src/components/auth/AuthLoadingScreen.tsx` | Full-page loading state | +| `packages/frontend/src/components/auth/ProtectedRoute.tsx` | Auth guard component | +| `packages/frontend/src/styles/auth.css` | Auth page CSS (loading animation, responsive overrides) | +| `packages/frontend/src/lib/clerkAppearance.ts` | Shared Clerk appearance config object | + +--- + +*This design spec governs the visual and interaction design for feat-002. For backend implementation details, API contracts, and test requirements, see `feat-002-spec.md`. For brand token definitions, see `specs/standards/brand.md`.* diff --git a/.claude/prds/feat-002-research.md b/.claude/prds/feat-002-research.md new file mode 100644 index 0000000..1c8c8c2 --- /dev/null +++ b/.claude/prds/feat-002-research.md @@ -0,0 +1,250 @@ +# feat-002: Authentication — Research + +## Clerk Express Middleware Pattern + +`@clerk/express` v1.x is already installed in `packages/backend/package.json`. The package exposes: + +- `clerkMiddleware()` — global middleware that processes the Clerk session token from incoming requests. Must be registered **before** any route that needs auth. It does not reject requests; it populates auth context on the request object. +- `requireAuth()` — middleware that rejects unauthenticated requests with 401. Use this on protected routes. +- `getAuth(req)` — utility to extract the auth object from the Express request. Returns `{ userId, sessionId, orgId, ... }`. The `userId` field is the Clerk user ID (string, e.g. `user_2abc...`). + +Pattern in `server.ts`: + +```ts +import { clerkMiddleware, requireAuth, getAuth } from '@clerk/express'; + +app.use(clerkMiddleware()); +// Health check stays before requireAuth +app.get('/health', ...); +// Protected routes +app.use('/v1', requireAuth(), v1Router); +``` + +In a controller: + +```ts +const { userId } = getAuth(req); +// userId is the clerk_id — map to internal user via repo +``` + +TypeScript augmentation: `@clerk/express` extends Express's `Request` type — `req.auth` is available and typed as `AuthObject` after `clerkMiddleware()` runs. No manual type declaration needed. + +## User Sync Strategy + +**Lazy sync on first authenticated request is the correct approach for this feature.** + +The PRD's AC for `GET /v1/me` specifies: "creates the user record on first login (upsert by `clerk_id`)". This means: + +1. User authenticates via Clerk (frontend). +2. Frontend calls `GET /v1/me` with the JWT. +3. Backend middleware verifies JWT, extracts `userId` (Clerk ID). +4. Application service does `INSERT ... ON CONFLICT (clerk_id) DO UPDATE` (upsert). +5. Returns the local user record. + +**Webhook approach is out of scope for feat-002.** The `.env.example` has `CLERK_WEBHOOK_SIGNING_SECRET` present but it is for future use. Webhooks require a public endpoint; for local demo, lazy sync is sufficient and simpler. Webhooks are theatre for this scope. + +The upsert should pull `email` and optionally `firstName`/`lastName` from the Clerk JWT claims (standard OIDC claims are present in the JWT: `email`, `given_name`, `family_name`). Alternatively, call the Clerk backend API using `@clerk/express`'s `clerkClient` to fetch user details at sync time — but JWT claims are sufficient for `email`. + +## Database Schema + +Migration file: `db/migrations/20260307HHMMSS_create_users_table.sql` + +The PRD acceptance criteria specify the exact columns: + +```sql +-- migrate:up +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clerk_id VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + display_name VARCHAR(255), + avatar_url VARCHAR(500), + bio TEXT, + roles TEXT[] NOT NULL DEFAULT '{backer}', + kyc_status VARCHAR(50) NOT NULL DEFAULT 'not_verified', + onboarding_completed BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_clerk_id ON users(clerk_id); +CREATE INDEX idx_users_email ON users(email); + +ALTER TABLE users ADD CONSTRAINT chk_users_kyc_status + CHECK (kyc_status IN ('not_verified', 'pending', 'in_review', 'verified', 'failed', 'expired')); + +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- migrate:down +DROP TABLE IF EXISTS users; +``` + +Key notes: +- `update_updated_at_column()` function already exists from migration `20260305120000_add_updated_at_trigger.sql` — do NOT redefine it. +- `roles` is `TEXT[]` per PRD decision ("PostgreSQL text array for simplicity at this stage"). +- `kyc_status` values must align with the KYC domain lifecycle defined in L4-005 (Pending, In Review, Verified, Failed, Expired) plus the initial `not_verified` state. +- The template in `.template.sql` references `accounts(id)` for the FK — for the users table itself there is no parent FK; other tables will reference `users(id)`. + +## RBAC Design + +**Roles stored as `TEXT[]` on the users table** — this is explicitly decided in the PRD ("Key Decisions" section). + +The five roles from L4-001 and L3-002: `backer`, `creator`, `reviewer`, `administrator`, `super_administrator`. + +Storage format: lowercase snake_case strings in the array, e.g. `{backer}`, `{backer,creator}`. + +**Enforcement pattern:** + +1. Clerk JWT does NOT carry MMF roles — roles live only in the local `users` table. +2. After auth middleware extracts `clerk_id`, the application service loads the local user record (which includes `roles`). +3. A role-guard middleware or helper checks `user.roles.includes('administrator')` before proceeding. +4. The `POST /v1/admin/users/:id/roles` endpoint requires the calling user to have `administrator` role — enforced at the controller layer. +5. Domain rule: `super_administrator` cannot be assigned through the standard UI (AC-ACCT-014 in L4-001). + +**Role check helper pattern (in application service / controller):** + +```ts +function requireRole(user: User, role: string): void { + if (!user.roles.includes(role)) { + throw new RoleAssignmentForbiddenError(); // extends DomainError + } +} +``` + +## Mock Auth Pattern + +`MOCK_AUTH=true` is present in `.env.example`. The pattern should be: + +1. Create an `AuthPort` interface in `packages/backend/src/shared/ports/` (or `account/ports/`). +2. Create a real `ClerkAuthAdapter` that calls `clerkMiddleware()` + `getAuth()`. +3. Create a `MockAuthAdapter` that bypasses JWT verification and injects a fixed test user context. +4. In `server.ts`, select the adapter based on `process.env.MOCK_AUTH === 'true'`. + +**Concrete mock middleware pattern:** + +```ts +// MockAuthMiddleware — for MOCK_AUTH=true +export function mockAuthMiddleware(req: Request, _res: Response, next: NextFunction) { + (req as AuthenticatedRequest).auth = { + userId: 'mock_user_clerk_id', + // other fields as needed + }; + next(); +} +``` + +Integration tests that need auth can set `MOCK_AUTH=true` or inject a test-specific middleware. For API integration tests using SuperTest, the pattern is to set `MOCK_AUTH=true` in the test environment and rely on the mock adapter. + +**Important:** `requireAuth()` from `@clerk/express` will still check for a real Clerk token even if `clerkMiddleware()` is bypassed. When `MOCK_AUTH=true`, skip both `clerkMiddleware()` and `requireAuth()` and use the mock middleware instead. + +## Frontend Auth Setup + +**Package installed:** `@clerk/react` v5.x in `packages/frontend/package.json`. + +**ClerkProvider placement:** Wrap the app in `main.tsx` (not `App.tsx`), wrapping the `` content. This ensures Clerk is initialised before anything renders. + +```tsx +// main.tsx +import { ClerkProvider } from '@clerk/react'; + +createRoot(rootElement).render( + + + + + +); +``` + +`VITE_CLERK_PUBLISHABLE_KEY` is already documented in `.env.example`. + +**useAuth() hook:** Returns `{ isLoaded, isSignedIn, userId, getToken, signOut }`. `getToken()` returns the JWT for API requests. The centralised API client must call `getToken()` and inject it as a Bearer token. + +**API client pattern:** + +```ts +const token = await getToken(); +fetch('/v1/me', { headers: { Authorization: `Bearer ${token}` } }); +``` + +**Sign in / Sign up routing:** Clerk provides `` and `` components. Routes: +- `/sign-in` — renders `` +- `/sign-up` — renders `` + +These must be public routes (outside `ProtectedRoute`). + +**ProtectedRoute pattern:** + +```tsx +import { useAuth } from '@clerk/react'; +import { Navigate } from 'react-router'; + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isLoaded, isSignedIn } = useAuth(); + if (!isLoaded) return ; + if (!isSignedIn) return ; + return <>{children}; +} +``` + +**AuthContext pattern (per PRD AC):** The PRD requires `GET /v1/me` result stored in a React Context. The pattern is: +1. `AuthContext` holds the local `User` object (from our database, not just Clerk claims). +2. A `useCurrentUser()` hook consumes the context. +3. On app load (inside a component after ClerkProvider), call `GET /v1/me` when `isSignedIn === true`. +4. Use TanStack Query for the fetch — the result populates context. + +**Session management:** Clerk handles JWT expiry and refresh automatically. The `getToken()` call always returns a fresh token (Clerk refreshes in the background). Access token lifetime is 5 minutes per L3-002 Section 4.3 — this is configured in the Clerk dashboard, not in code. + +## App Routing Structure + +Current `App.tsx` uses `BrowserRouter` with React Router v7. The updated structure should be: + +```tsx +// Routes: +// / → redirects to /home or /sign-in based on auth +// /sign-in/* → Clerk SignIn (public) +// /sign-up/* → Clerk SignUp (public) +// /home → ProtectedRoute → HomePage +// /profile → ProtectedRoute → ProfilePage (feat-002) +``` + +React Router v7 is already installed. The `` and `` components need wildcard routes (`/sign-in/*`) because Clerk's component handles its own sub-routing internally. + +## Hexagonal Architecture Layout for Account Domain + +The `account/` directories already exist (all empty with `.gitkeep`): + +``` +packages/backend/src/account/ + domain/ — User entity, domain errors, value objects + ports/ — IUserRepository interface, IAuthContext interface + application/ — UserService (orchestrates via ports) + adapters/ — UserRepository (pg), ClerkAuthAdapter, MockAuthAdapter + api/ — Express router, controllers +``` + +The `shared/ports/` directory exists for cross-cutting port interfaces. + +## Key Decisions for Spec Writer + +1. **Mock auth middleware typing:** `req.auth` is typed by `@clerk/express` — when using mock auth, the type must be compatible. Consider defining a shared `AuthContext` interface that both real and mock adapters satisfy, to avoid casting. + +2. **Clerk JWT claims vs. backend API for email sync:** The Clerk JWT contains `email` as a standard OIDC claim at `req.auth.sessionClaims.email`. This is sufficient for the initial upsert. No need to call the Clerk backend API for basic email — but it should be documented as the authoritative source. + +3. **roles array validation:** When `POST /v1/admin/users/:id/roles` is called, Zod should validate that assigned roles are members of the allowed set. Super Administrator cannot be assigned via this endpoint (domain rule from AC-ACCT-014). + +4. **`clerk_id` vs `id` in API responses:** The external API should expose the internal `id` (UUID), not `clerk_id`. The `clerk_id` is an internal join key and should not appear in API responses. + +5. **`GET /v1/me` upsert behaviour:** First call creates the user with `{backer}` role and `not_verified` kyc_status. Subsequent calls return the existing record. The upsert should only update `email` if it has changed in Clerk (email can change on Clerk's side). + +6. **`onboarding_completed` flag:** Set to `false` on creation. The frontend should check this field from `GET /v1/me` to decide whether to show the onboarding flow (feat-003). + +7. **Error codes to define:** `USER_NOT_FOUND`, `ROLE_ASSIGNMENT_FORBIDDEN`, `INVALID_ROLE`, `SUPER_ADMIN_ASSIGNMENT_RESTRICTED`. These extend `DomainError` per the established pattern in `packages/backend/src/shared/domain/errors/DomainError.ts`. + +8. **`@clerk/express` version:** Installed as `^1.0.0`. The `clerkMiddleware` / `getAuth` / `requireAuth` API is the v1 API. Do not use the deprecated `ClerkExpressRequireAuth` from v0. + +9. **Migration timestamp:** Must use current date format `YYYYMMDDHHMMSS`. Since today is 2026-03-07, use `20260307HHMMSS` (e.g. `20260307120000`). Must come after `20260305120000` (the existing trigger migration). + +10. **Frontend `MOCK_AUTH`:** There is no frontend equivalent — `MOCK_AUTH=true` only affects the backend JWT verification. In frontend tests, use MSW (already installed) to intercept `GET /v1/me` and return a fixture user. diff --git a/.claude/prds/feat-002-spec.md b/.claude/prds/feat-002-spec.md new file mode 100644 index 0000000..e9a48a8 --- /dev/null +++ b/.claude/prds/feat-002-spec.md @@ -0,0 +1,1156 @@ +# feat-002: Authentication and User Management — Technical Spec + +**Feature ID:** feat-002 +**Bounded Context:** Account +**Priority:** P0 +**Dependencies:** feat-001 (infrastructure baseline) +**Status:** Ready for Implementation +**Date:** 2026-03-07 + +--- + +## 1. Overview + +This feature delivers the identity foundation for Mars Mission Fund. It integrates Clerk as the authentication provider and establishes a local shadow user record in PostgreSQL keyed on `clerk_id`. Every other feature depends on this for access control. + +**What is implemented (real, not theatre):** +- Clerk JWT middleware wired into Express — all routes except `/health` require a valid Clerk JWT +- Local `users` table with lazy-sync upsert on first authenticated request +- `User` domain entity with `create()` / `reconstitute()` factory methods +- RBAC roles stored as a `TEXT[]` column on `users`; `backer` assigned by default +- `GET /v1/me` — returns authenticated user profile, creates record on first call +- `PATCH /v1/me` — updates `display_name` and `bio` +- `GET /v1/me/roles` — returns current user's roles array +- `POST /v1/admin/users/:id/roles` — Administrator assigns/removes roles (not Super Administrator) +- Frontend: `ClerkProvider` wrapping, sign-in/sign-up pages, `ProtectedRoute`, API client with JWT injection + +**What is out of scope (theatre or deferred):** +- Session elevation / MFA enforcement (Clerk handles session management transparently) +- Onboarding flow UI (feat-003) +- Full KYC verification flow (feat-007) +- SSO provider linking UI (Clerk handles transparently) +- Profile picture upload +- Account deletion / GDPR erasure workflow (P3) +- Data portability export (P3) + +**Spec conflict notes:** +- The PRD (feat-002-auth-and-user-management.md) specifies `kyc_status` default `'not_verified'` and check constraint values aligned with the KYC domain lifecycle (`not_verified`, `pending`, `in_review`, `verified`, `failed`, `expired`). The spec task brief uses `'not_started'` and a shorter set. The research file (feat-002-research.md) aligns with the PRD values. L4-001 Section 8.1 references KYC lifecycle states (`Pending`, `In Review`, `Verified`, `Failed`, `Expired`) plus an initial state. The PRD is the higher-authority input; this spec uses the PRD values: `not_verified`, `pending`, `in_review`, `verified`, `failed`, `expired`. +- The PRD specifies `PATCH /v1/me` allows updating `display_name`, `bio`, and `avatar_url`. This spec includes `avatar_url` in the PATCH endpoint per the PRD. + +--- + +## 2. Database Migration + +**File:** `db/migrations/20260307120000_create_users.sql` + +```sql +-- migrate:up + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clerk_id VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + display_name VARCHAR(255), + avatar_url VARCHAR(500), + bio TEXT, + roles TEXT[] NOT NULL DEFAULT '{backer}', + kyc_status VARCHAR(50) NOT NULL DEFAULT 'not_verified', + onboarding_completed BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_users_kyc_status CHECK ( + kyc_status IN ('not_verified', 'pending', 'in_review', 'verified', 'failed', 'expired') + ) +); + +CREATE INDEX idx_users_clerk_id ON users (clerk_id); +CREATE INDEX idx_users_email ON users (email); + +-- update_updated_at_column() is defined in 20260305120000_add_updated_at_trigger.sql +-- Do NOT redefine it here. +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- migrate:down + +DROP TABLE IF EXISTS users; +``` + +**Notes:** +- `update_updated_at_column()` function already exists from migration `20260305120000_add_updated_at_trigger.sql`. Do not redefine it. +- `roles` uses PostgreSQL `TEXT[]` with literal default `'{backer}'` (not `ARRAY['backer']` — the latter is not valid as a column default in PostgreSQL DDL). +- `kyc_status` values match the KYC domain lifecycle defined in L4-005 plus the initial `not_verified` state. +- `email` has a `UNIQUE` constraint because Clerk guarantees one account per verified email. +- No `BEGIN; ... COMMIT;` wrapper — dbmate wraps each migration in a transaction automatically. + +--- + +## 3. Backend Domain Layer + +### 3.1 `packages/backend/src/account/domain/Role.ts` + +```typescript +export const Role = { + Backer: 'backer', + Creator: 'creator', + Reviewer: 'reviewer', + Administrator: 'administrator', + SuperAdministrator: 'super_administrator', +} as const; + +export type Role = (typeof Role)[keyof typeof Role]; + +export const ALL_ROLES: Role[] = Object.values(Role); + +export const ADMIN_ROLES: Role[] = [ + Role.Reviewer, + Role.Administrator, + Role.SuperAdministrator, +]; +``` + +**Rules:** +- Roles are stored as lowercase snake_case strings in the database array. +- `super_administrator` cannot be assigned through the standard role assignment endpoint (AC-ACCT-014). + +### 3.2 `packages/backend/src/account/domain/KycStatus.ts` + +```typescript +export const KycStatus = { + NotVerified: 'not_verified', + Pending: 'pending', + InReview: 'in_review', + Verified: 'verified', + Failed: 'failed', + Expired: 'expired', +} as const; + +export type KycStatus = (typeof KycStatus)[keyof typeof KycStatus]; +``` + +### 3.3 `packages/backend/src/account/domain/User.ts` + +Entity with private constructor. All properties `readonly`. Two factory methods: `create()` validates inputs; `reconstitute()` skips validation (for DB hydration). + +```typescript +import { Result } from '../../shared/domain/Result'; +import { Role } from './Role'; +import { KycStatus } from './KycStatus'; + +export interface UserProps { + id: string; + clerkId: string; + email: string; + displayName: string | null; + avatarUrl: string | null; + bio: string | null; + roles: Role[]; + kycStatus: KycStatus; + onboardingCompleted: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class User { + readonly id: string; + readonly clerkId: string; + readonly email: string; + readonly displayName: string | null; + readonly avatarUrl: string | null; + readonly bio: string | null; + readonly roles: Role[]; + readonly kycStatus: KycStatus; + readonly onboardingCompleted: boolean; + readonly createdAt: Date; + readonly updatedAt: Date; + + private constructor(props: UserProps) { + this.id = props.id; + this.clerkId = props.clerkId; + this.email = props.email; + this.displayName = props.displayName; + this.avatarUrl = props.avatarUrl; + this.bio = props.bio; + this.roles = props.roles; + this.kycStatus = props.kycStatus; + this.onboardingCompleted = props.onboardingCompleted; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; + } + + static create(props: { + clerkId: string; + email: string; + displayName?: string | null; + avatarUrl?: string | null; + bio?: string | null; + }): Result { + // Validation rules: + // - clerkId must be a non-empty string + // - email must match a basic email format + // Returns Result.fail() with UserValidationError on invalid input + // On success, returns Result.ok() with a new User with: + // - id: generated via crypto.randomUUID() + // - roles: [Role.Backer] + // - kycStatus: KycStatus.NotVerified + // - onboardingCompleted: false + // - createdAt / updatedAt: new Date() + } + + static reconstitute(props: UserProps): User { + return new User(props); + } + + hasRole(role: Role): boolean { + return this.roles.includes(role); + } + + isAdmin(): boolean { + return ( + this.hasRole(Role.Reviewer) || + this.hasRole(Role.Administrator) || + this.hasRole(Role.SuperAdministrator) + ); + } +} +``` + +**Validation rules in `create()`:** +- `clerkId` must be a non-empty string (trim; reject empty or whitespace-only) +- `email` must pass a basic RFC 5321 format check (presence of `@` and a domain part is sufficient; Clerk already validates email format) +- Return `Result.fail(new UserValidationError(...))` if either check fails +- Never throws — always returns a `Result` + +### 3.4 Domain Errors + +**`packages/backend/src/account/domain/errors/UserNotFoundError.ts`** + +```typescript +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class UserNotFoundError extends DomainError { + readonly code = 'USER_NOT_FOUND'; + + constructor() { + super('User not found.'); + } +} +``` + +**`packages/backend/src/account/domain/errors/UserAlreadyExistsError.ts`** + +```typescript +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class UserAlreadyExistsError extends DomainError { + readonly code = 'USER_ALREADY_EXISTS'; + + constructor() { + super('A user with this identity already exists.'); + } +} +``` + +**`packages/backend/src/account/domain/errors/RoleAssignmentForbiddenError.ts`** + +```typescript +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class RoleAssignmentForbiddenError extends DomainError { + readonly code = 'ROLE_ASSIGNMENT_FORBIDDEN'; + + constructor() { + super('You do not have permission to assign this role.'); + } +} +``` + +**`packages/backend/src/account/domain/errors/InvalidRoleError.ts`** + +```typescript +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class InvalidRoleError extends DomainError { + readonly code = 'INVALID_ROLE'; + + constructor(role: string) { + super(`'${role}' is not a valid role.`); + } +} +``` + +**`packages/backend/src/account/domain/errors/SuperAdminAssignmentRestrictedError.ts`** + +```typescript +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class SuperAdminAssignmentRestrictedError extends DomainError { + readonly code = 'SUPER_ADMIN_ASSIGNMENT_RESTRICTED'; + + constructor() { + super('Super Administrator role cannot be assigned through this endpoint.'); + } +} +``` + +**`packages/backend/src/account/domain/errors/UserValidationError.ts`** + +```typescript +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class UserValidationError extends DomainError { + readonly code = 'USER_VALIDATION_ERROR'; + + constructor(message: string) { + super(message); + } +} +``` + +--- + +## 4. Backend Ports + +### 4.1 `packages/backend/src/account/ports/UserRepository.ts` + +```typescript +import { User } from '../domain/User'; + +export interface UserRepository { + findByClerkId(clerkId: string): Promise; + findById(id: string): Promise; + upsert(user: User): Promise; + updateProfile( + id: string, + fields: { displayName?: string | null; bio?: string | null; avatarUrl?: string | null } + ): Promise; + updateRoles(id: string, roles: string[]): Promise; +} +``` + +### 4.2 `packages/backend/src/shared/ports/AuthPort.ts` + +```typescript +import type { Request, RequestHandler } from 'express'; + +export interface AuthContext { + clerkUserId: string; +} + +export interface AuthPort { + /** + * Extracts auth context from an Express request after middleware has run. + * Returns null if not authenticated. + */ + getAuthContext(req: Request): AuthContext | null; + + /** + * Returns an Express middleware that rejects unauthenticated requests with 401. + */ + requireAuthMiddleware(): RequestHandler; + + /** + * Returns an Express middleware that populates auth context on the request. + * Does NOT reject unauthenticated requests. + */ + globalMiddleware(): RequestHandler; +} +``` + +--- + +## 5. Backend Adapters + +### 5.1 `packages/backend/src/shared/adapters/auth/ClerkAuthAdapter.ts` + +Wraps `@clerk/express` v1.x. Implements `AuthPort`. + +```typescript +import { clerkMiddleware, requireAuth, getAuth } from '@clerk/express'; +import type { Request, RequestHandler } from 'express'; +import type { AuthPort, AuthContext } from '../../ports/AuthPort'; + +export class ClerkAuthAdapter implements AuthPort { + getAuthContext(req: Request): AuthContext | null { + const auth = getAuth(req); + if (!auth.userId) return null; + return { clerkUserId: auth.userId }; + } + + requireAuthMiddleware(): RequestHandler { + return requireAuth(); + } + + globalMiddleware(): RequestHandler { + return clerkMiddleware(); + } +} +``` + +**Important:** `clerkMiddleware()` populates `req.auth` but does NOT reject unauthenticated requests. `requireAuth()` rejects with 401. Use `clerkMiddleware()` globally and `requireAuth()` only on protected route groups. + +### 5.2 `packages/backend/src/shared/adapters/auth/MockAuthAdapter.ts` + +Used when `MOCK_AUTH=true`. Implements `AuthPort`. Bypasses all JWT verification. + +```typescript +import type { Request, RequestHandler } from 'express'; +import type { AuthPort, AuthContext } from '../../ports/AuthPort'; + +export const MOCK_CLERK_USER_ID = 'mock_user_clerk_id'; + +export class MockAuthAdapter implements AuthPort { + getAuthContext(_req: Request): AuthContext { + return { clerkUserId: MOCK_CLERK_USER_ID }; + } + + requireAuthMiddleware(): RequestHandler { + return (_req, _res, next) => next(); + } + + globalMiddleware(): RequestHandler { + return (req, _res, next) => { + // Attach a compatible auth object so getAuth(req) works if called + (req as Request & { auth: AuthContext }).auth = { clerkUserId: MOCK_CLERK_USER_ID }; + next(); + }; + } +} +``` + +**Note:** When `MOCK_AUTH=true`, do not call `clerkMiddleware()` or `requireAuth()` from Clerk — they will attempt real JWT validation. The `MockAuthAdapter` replaces both. + +### 5.3 `packages/backend/src/account/adapters/UserRepositoryPg.ts` + +Implements `UserRepository` using the shared `pool` singleton from `packages/backend/src/shared/adapters/db/pool.ts`. + +```typescript +import { Pool } from 'pg'; +import type { UserRepository } from '../ports/UserRepository'; +import { User } from '../domain/User'; +import type { Role } from '../domain/Role'; +import type { KycStatus } from '../domain/KycStatus'; + +export class UserRepositoryPg implements UserRepository { + constructor(private readonly pool: Pool) {} + + async findByClerkId(clerkId: string): Promise { + // SELECT * FROM users WHERE clerk_id = $1 + // Map row to User.reconstitute(...) + // Return null if no row found + } + + async findById(id: string): Promise { + // SELECT * FROM users WHERE id = $1 + // Map row to User.reconstitute(...) + // Return null if no row found + } + + async upsert(user: User): Promise { + // INSERT INTO users (id, clerk_id, email, display_name, avatar_url, bio, roles, + // kyc_status, onboarding_completed, created_at, updated_at) + // VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + // ON CONFLICT (clerk_id) DO UPDATE SET + // email = EXCLUDED.email, + // updated_at = NOW() + // RETURNING * + // Map returned row to User.reconstitute(...) + } + + async updateProfile( + id: string, + fields: { displayName?: string | null; bio?: string | null; avatarUrl?: string | null } + ): Promise { + // Build dynamic SET clause from provided fields using parameterised values + // UPDATE users SET , updated_at = NOW() WHERE id = $N RETURNING * + // Return null if no row updated (user not found) + } + + async updateRoles(id: string, roles: string[]): Promise { + // UPDATE users SET roles = $1, updated_at = NOW() WHERE id = $2 RETURNING * + // Return null if no row updated + } +} +``` + +**Row mapping helper (private):** +```typescript +private mapRow(row: Record): User { + return User.reconstitute({ + id: row.id as string, + clerkId: row.clerk_id as string, + email: row.email as string, + displayName: (row.display_name as string | null) ?? null, + avatarUrl: (row.avatar_url as string | null) ?? null, + bio: (row.bio as string | null) ?? null, + roles: row.roles as Role[], + kycStatus: row.kyc_status as KycStatus, + onboardingCompleted: row.onboarding_completed as boolean, + createdAt: row.created_at as Date, + updatedAt: row.updated_at as Date, + }); +} +``` + +**Constraints:** +- Parameterised queries only — no string interpolation in query construction. +- `upsert()` updates only `email` and `updated_at` on conflict — it does not overwrite roles, kyc_status, or onboarding_completed. +- `updateProfile()` must only update the fields that are explicitly passed (partial update). Build the SET clause dynamically but safely using parameterised values. + +--- + +## 6. Backend Application Services + +### 6.1 `packages/backend/src/account/application/GetOrCreateUserService.ts` + +```typescript +import type { UserRepository } from '../ports/UserRepository'; +import { User } from '../domain/User'; + +export class GetOrCreateUserService { + constructor(private readonly userRepo: UserRepository) {} + + async execute(clerkId: string, email: string): Promise { + const existing = await this.userRepo.findByClerkId(clerkId); + if (existing) return existing; + + const result = User.create({ clerkId, email }); + if (result.isFailure) { + throw result.error; + } + + return await this.userRepo.upsert(result.value); + } +} +``` + +**Behaviour:** +- Tries `findByClerkId` first. +- If not found: calls `User.create()`, then `userRepo.upsert()`. +- `upsert()` uses `ON CONFLICT (clerk_id) DO UPDATE` — safe against race conditions on concurrent first-login requests. +- The `email` value comes from the Clerk JWT session claims (`req.auth.sessionClaims?.email` after `clerkMiddleware()` runs). This is the authoritative source for email at sync time. + +### 6.2 `packages/backend/src/account/application/UpdateUserProfileService.ts` + +```typescript +import type { UserRepository } from '../ports/UserRepository'; +import { User } from '../domain/User'; +import { UserNotFoundError } from '../domain/errors/UserNotFoundError'; + +export class UpdateUserProfileService { + constructor(private readonly userRepo: UserRepository) {} + + async execute( + userId: string, + fields: { displayName?: string | null; bio?: string | null; avatarUrl?: string | null } + ): Promise { + const updated = await this.userRepo.updateProfile(userId, fields); + if (!updated) throw new UserNotFoundError(); + return updated; + } +} +``` + +### 6.3 `packages/backend/src/account/application/AssignRolesService.ts` + +```typescript +import type { UserRepository } from '../ports/UserRepository'; +import { User } from '../domain/User'; +import { Role, ALL_ROLES } from '../domain/Role'; +import { UserNotFoundError } from '../domain/errors/UserNotFoundError'; +import { RoleAssignmentForbiddenError } from '../domain/errors/RoleAssignmentForbiddenError'; +import { InvalidRoleError } from '../domain/errors/InvalidRoleError'; +import { SuperAdminAssignmentRestrictedError } from '../domain/errors/SuperAdminAssignmentRestrictedError'; + +export class AssignRolesService { + constructor(private readonly userRepo: UserRepository) {} + + async execute( + actorUser: User, + targetUserId: string, + newRoles: string[] + ): Promise { + // 1. Actor must have Administrator role + if (!actorUser.hasRole(Role.Administrator)) { + throw new RoleAssignmentForbiddenError(); + } + + // 2. Validate all requested roles are known values + for (const role of newRoles) { + if (!ALL_ROLES.includes(role as Role)) { + throw new InvalidRoleError(role); + } + } + + // 3. Super Administrator cannot be assigned through this endpoint (AC-ACCT-014) + if (newRoles.includes(Role.SuperAdministrator)) { + throw new SuperAdminAssignmentRestrictedError(); + } + + // 4. Apply role update + const updated = await this.userRepo.updateRoles(targetUserId, newRoles); + if (!updated) throw new UserNotFoundError(); + return updated; + } +} +``` + +--- + +## 7. Backend API Layer + +### 7.1 `packages/backend/src/account/api/account.schemas.ts` + +```typescript +import { z } from 'zod'; + +export const patchMeSchema = z.object({ + display_name: z.string().max(255).nullable().optional(), + bio: z.string().nullable().optional(), + avatar_url: z.string().url().max(500).nullable().optional(), +}); + +export type PatchMeBody = z.infer; + +export const assignRolesSchema = z.object({ + roles: z.array(z.string()).min(1), +}); + +export type AssignRolesBody = z.infer; +``` + +### 7.2 `packages/backend/src/account/api/account.router.ts` + +Routes: + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/v1/me` | Required | Get or create authenticated user's profile | +| `PATCH` | `/v1/me` | Required | Update display_name, bio, avatar_url | +| `GET` | `/v1/me/roles` | Required | Get current user's roles array | +| `POST` | `/v1/admin/users/:id/roles` | Required + Administrator | Assign roles to a user | + +**Controller logic for `GET /v1/me`:** +1. Extract `clerkUserId` from auth context via `authPort.getAuthContext(req)`. +2. Extract `email` from Clerk session claims: `getAuth(req).sessionClaims?.email` (cast to string; fall back to empty string if absent — Clerk guarantees email is present for verified accounts). +3. Call `GetOrCreateUserService.execute(clerkUserId, email)`. +4. Serialize and return the user profile response (see Section 7.3). + +**Controller logic for `PATCH /v1/me`:** +1. Validate request body against `patchMeSchema` — return 400 on validation failure. +2. Extract `clerkUserId` from auth context. +3. Load user via `userRepo.findByClerkId(clerkUserId)` — return 404 if not found (should not happen in normal flow). +4. Call `UpdateUserProfileService.execute(user.id, { displayName, bio, avatarUrl })`. +5. Return updated profile. + +**Controller logic for `GET /v1/me/roles`:** +1. Extract `clerkUserId` from auth context. +2. Load user via `userRepo.findByClerkId(clerkUserId)`. +3. Return `{ roles: user.roles }`. + +**Controller logic for `POST /v1/admin/users/:id/roles`:** +1. Validate body against `assignRolesSchema`. +2. Load actor user via `findByClerkId(clerkUserId)`. +3. Call `AssignRolesService.execute(actorUser, req.params.id, body.roles)`. +4. Return updated target user profile. +5. Role changes must be logged (use Pino logger with structured fields: actor_id, target_id, new_roles, timestamp). + +**Error mapping:** +| Domain Error Code | HTTP Status | +|---|---| +| `USER_NOT_FOUND` | 404 | +| `USER_ALREADY_EXISTS` | 409 | +| `ROLE_ASSIGNMENT_FORBIDDEN` | 403 | +| `INVALID_ROLE` | 400 | +| `SUPER_ADMIN_ASSIGNMENT_RESTRICTED` | 403 | +| `USER_VALIDATION_ERROR` | 400 | +| Any unhandled error | 500 | + +### 7.3 API Response Shape + +**User profile response** (returned by `GET /v1/me`, `PATCH /v1/me`, and `POST /v1/admin/users/:id/roles`): + +```typescript +{ + id: string, // Internal UUID — NOT clerk_id + email: string, + displayName: string | null, + avatarUrl: string | null, + bio: string | null, + roles: string[], + kycStatus: string, + onboardingCompleted: boolean +} +``` + +`clerk_id` is NEVER returned in any API response. It is an internal join key only. + +**Roles response** (returned by `GET /v1/me/roles`): +```typescript +{ + roles: string[] +} +``` + +**Error response format** (per L3-001, Section 6.1): +```json +{ + "error": { + "code": "USER_NOT_FOUND", + "message": "User not found.", + "correlation_id": "" + } +} +``` + +--- + +## 8. Server.ts Updates + +The following changes are required to `packages/backend/src/server.ts`: + +1. **Auth adapter selection** based on `process.env.MOCK_AUTH`: + +```typescript +import { ClerkAuthAdapter } from './shared/adapters/auth/ClerkAuthAdapter'; +import { MockAuthAdapter } from './shared/adapters/auth/MockAuthAdapter'; +import type { AuthPort } from './shared/ports/AuthPort'; + +const authAdapter: AuthPort = + process.env.MOCK_AUTH === 'true' + ? new MockAuthAdapter() + : new ClerkAuthAdapter(); +``` + +2. **Global middleware** — register before all routes: + +```typescript +app.use(authAdapter.globalMiddleware()); +``` + +3. **Health check** — mount before auth-protected routes: + +```typescript +app.get('/health', (_req, res) => { res.json({ status: 'ok' }); }); +``` + +4. **Protected route group** — all `/v1` routes require auth: + +```typescript +const v1Router = express.Router(); +v1Router.use(authAdapter.requireAuthMiddleware()); +// Mount sub-routers +v1Router.use(accountRouter); +app.use('/v1', v1Router); +``` + +5. **Dependency wiring** — construct and inject dependencies: + +```typescript +import { pool } from './shared/adapters/db/pool'; +import { UserRepositoryPg } from './account/adapters/UserRepositoryPg'; +import { GetOrCreateUserService } from './account/application/GetOrCreateUserService'; +import { UpdateUserProfileService } from './account/application/UpdateUserProfileService'; +import { AssignRolesService } from './account/application/AssignRolesService'; +import { createAccountRouter } from './account/api/account.router'; + +const userRepo = new UserRepositoryPg(pool); +const getOrCreateUserService = new GetOrCreateUserService(userRepo); +const updateUserProfileService = new UpdateUserProfileService(userRepo); +const assignRolesService = new AssignRolesService(userRepo); + +const accountRouter = createAccountRouter({ + authAdapter, + userRepo, + getOrCreateUserService, + updateUserProfileService, + assignRolesService, +}); +``` + +--- + +## 9. Frontend Layer + +### 9.1 `packages/frontend/src/main.tsx` + +Wrap the app in `ClerkProvider` before `App` renders: + +```tsx +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ClerkProvider } from '@clerk/react'; +import App from './App'; +import './styles/global.css'; + +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('Root element not found'); + +createRoot(rootElement).render( + + + + + +); +``` + +`VITE_CLERK_PUBLISHABLE_KEY` is already documented in `.env.example`. + +### 9.2 `packages/frontend/src/api/client.ts` + +Centralised fetch wrapper that injects the Clerk JWT as a Bearer token. Must be used for all API calls. + +```typescript +// Returns a configured fetch function bound to the provided getToken function. +// Usage: const apiClient = createApiClient(getToken); +// const user = await apiClient('/v1/me'); + +export interface ApiClient { + get(path: string): Promise; + patch(path: string, body: unknown): Promise; + post(path: string, body: unknown): Promise; +} + +export function createApiClient( + getToken: () => Promise +): ApiClient { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; + + async function request(path: string, init: RequestInit = {}): Promise { + const token = await getToken(); + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init.headers, + }, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new ApiError(response.status, error?.error?.code, error?.error?.message); + } + return response.json() as Promise; + } + + return { + get: (path: string) => request(path), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + post: (path: string, body: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + }; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string | undefined, + message: string | undefined + ) { + super(message ?? `HTTP ${status}`); + this.name = 'ApiError'; + } +} +``` + +`VITE_API_BASE_URL` should be added to `.env.example` with value `http://localhost:3001`. + +### 9.3 `packages/frontend/src/hooks/useCurrentUser.ts` + +```typescript +import { useAuth } from '@clerk/react'; +import { useQuery } from '@tanstack/react-query'; +import { createApiClient } from '../api/client'; + +export interface UserProfile { + id: string; + email: string; + displayName: string | null; + avatarUrl: string | null; + bio: string | null; + roles: string[]; + kycStatus: string; + onboardingCompleted: boolean; +} + +export function useCurrentUser() { + const { isSignedIn, getToken } = useAuth(); + const apiClient = createApiClient(getToken); + + return useQuery({ + queryKey: ['currentUser'], + queryFn: () => apiClient.get('/v1/me'), + enabled: isSignedIn === true, + staleTime: 5 * 60 * 1000, // 5 minutes — aligns with Clerk access token lifetime + }); +} +``` + +The `useCurrentUser` hook is the primary way to access the authenticated user's local profile throughout the app. It is backed by TanStack Query and re-fetches when the query is stale. + +### 9.4 `packages/frontend/src/components/auth/ProtectedRoute.tsx` + +```tsx +import { useAuth } from '@clerk/react'; +import { Navigate } from 'react-router'; + +interface ProtectedRouteProps { + readonly children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isLoaded, isSignedIn } = useAuth(); + + if (!isLoaded) { + // Use a semantic loading indicator respecting the design system + // See specs/standards/brand.md for motion tokens + return
; + } + + if (!isSignedIn) { + return ; + } + + return <>{children}; +} +``` + +### 9.5 `packages/frontend/src/pages/SignInPage.tsx` + +```tsx +import { SignIn } from '@clerk/react'; + +export default function SignInPage() { + return ( +
+ +
+ ); +} +``` + +### 9.6 `packages/frontend/src/pages/SignUpPage.tsx` + +```tsx +import { SignUp } from '@clerk/react'; + +export default function SignUpPage() { + return ( +
+ +
+ ); +} +``` + +### 9.7 `packages/frontend/src/App.tsx` — Router Updates + +Add routes for auth pages and protect the application routes: + +```tsx +import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; +import { ProtectedRoute } from './components/auth/ProtectedRoute'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; + +export default function App() { + return ( + + + {/* Public routes */} + } /> + } /> + + {/* Protected routes */} + + {/* HomePage placeholder — feat-003 onwards */} +
Home
+ + } + /> + + {/* Dashboard placeholder */} +
Dashboard
+ + } + /> + + {/* Root redirect */} + } /> +
+
+ ); +} +``` + +`/sign-in/*` and `/sign-up/*` use wildcard routes because Clerk's `` / `` components handle their own internal sub-routing (e.g., `/sign-in/factor-one`, `/sign-in/factor-two`). + +--- + +## 10. Tests + +### 10.1 Backend Unit Tests + +**`packages/backend/src/account/domain/User.test.ts`** + +Test cases: +- `create()` with valid inputs returns a `Result.ok` with a `User` having default `backer` role, `not_verified` kyc_status, `onboardingCompleted: false` +- `create()` with empty `clerkId` returns `Result.fail` +- `create()` with invalid email format returns `Result.fail` +- `reconstitute()` returns a `User` without validation (accepts any inputs) +- `hasRole()` returns `true` for an assigned role and `false` for an unassigned role +- `isAdmin()` returns `true` for Reviewer, Administrator, SuperAdministrator; `false` for Backer and Creator + +**`packages/backend/src/account/application/GetOrCreateUserService.test.ts`** + +Uses mock `UserRepository`. Test cases: +- Returns existing user when `findByClerkId` returns a user +- Creates and returns new user when `findByClerkId` returns null +- Propagates domain error when `User.create()` fails + +**`packages/backend/src/account/application/AssignRolesService.test.ts`** + +Uses mock `UserRepository`. Test cases: +- Throws `RoleAssignmentForbiddenError` when actor does not have Administrator role +- Throws `InvalidRoleError` for an unrecognised role string +- Throws `SuperAdminAssignmentRestrictedError` when `super_administrator` is in the roles list +- Updates roles successfully when actor is Administrator and roles are valid +- Throws `UserNotFoundError` when target user does not exist + +### 10.2 Backend Integration Tests + +**`packages/backend/src/account/adapters/UserRepositoryPg.test.ts`** + +Integration test using the real PostgreSQL pool (requires `DATABASE_URL` in environment). Tests: +- `upsert()` creates a new user record +- `upsert()` with an existing `clerk_id` updates `email` only — does not overwrite `roles` +- `findByClerkId()` returns the correct user +- `findById()` returns the correct user +- `updateProfile()` updates only the provided fields +- `updateRoles()` replaces the roles array + +**`packages/backend/src/account/api/account.router.test.ts`** + +SuperTest integration tests with `MOCK_AUTH=true`. Tests: +- `GET /v1/me` with no auth token → 401 (when using real Clerk adapter; skip for mock) +- `GET /v1/me` creates user record on first call and returns profile without `clerk_id` +- `GET /v1/me` returns existing user on subsequent calls +- `PATCH /v1/me` with valid body → 200, updated profile returned +- `PATCH /v1/me` with invalid body (e.g., `avatar_url` not a valid URL) → 400 +- `GET /v1/me/roles` returns the user's roles array +- `POST /v1/admin/users/:id/roles` with non-administrator actor → 403 +- `POST /v1/admin/users/:id/roles` with `super_administrator` in roles list → 403 +- `POST /v1/admin/users/:id/roles` with unknown role → 400 +- `POST /v1/admin/users/:id/roles` with valid request by Administrator → 200 + +### 10.3 Frontend Tests + +**`packages/frontend/src/components/auth/ProtectedRoute.test.tsx`** + +Uses `@testing-library/react` with mocked `@clerk/react`: +- Renders a loading indicator when `isLoaded` is `false` +- Redirects to `/sign-in` when `isLoaded` is `true` and `isSignedIn` is `false` +- Renders children when `isLoaded` is `true` and `isSignedIn` is `true` + +**`packages/frontend/src/hooks/useCurrentUser.test.ts`** + +Uses MSW to intercept `GET /v1/me`. Tests: +- Returns `isLoading: true` initially +- Returns user data after successful response +- Returns error state on API failure +- Does not fetch when `isSignedIn` is `false` + +--- + +## 11. Environment Variables + +No new environment variables are required. The following are already documented in `.env.example`: + +| Variable | Used By | Purpose | +|---|---|---| +| `CLERK_SECRET_KEY` | Backend | Clerk server-side secret for JWT validation | +| `VITE_CLERK_PUBLISHABLE_KEY` | Frontend | Clerk publishable key for `ClerkProvider` | +| `MOCK_AUTH` | Backend | Set to `true` to bypass Clerk JWT verification (dev/test) | + +One addition to `.env.example` is needed: + +| Variable | Value | Purpose | +|---|---|---| +| `VITE_API_BASE_URL` | `http://localhost:3001` | Base URL for frontend API client | + +--- + +## 12. Acceptance Criteria + +- [ ] `GET /v1/me` returns 401 without a valid auth token (using real Clerk adapter) +- [ ] `GET /v1/me` creates a new user record on first call with `backer` role and `not_verified` kyc_status +- [ ] `GET /v1/me` returns the existing record on subsequent calls (upsert is idempotent) +- [ ] `PATCH /v1/me` updates `display_name`, `bio`, and `avatar_url`; ignores any other fields in the request body +- [ ] `GET /v1/me/roles` returns the authenticated user's roles array +- [ ] `POST /v1/admin/users/:id/roles` assigns roles when the actor is an Administrator +- [ ] `POST /v1/admin/users/:id/roles` returns 403 when actor is not an Administrator +- [ ] `POST /v1/admin/users/:id/roles` returns 403 when `super_administrator` is in the requested roles +- [ ] `clerk_id` never appears in any API response +- [ ] Users have `backer` role by default on creation +- [ ] All error responses follow the format: `{ error: { code, message, correlation_id } }` +- [ ] Domain errors extend `DomainError` with unique `code` values +- [ ] Frontend renders Clerk's Sign In page at `/sign-in` +- [ ] Frontend renders Clerk's Sign Up page at `/sign-up` +- [ ] Authenticated users can access protected routes; unauthenticated users are redirected to `/sign-in` +- [ ] `ClerkProvider` wraps the React app in `main.tsx` +- [ ] `GET /v1/me` is called on app load via `useCurrentUser` hook when user is signed in +- [ ] All tests pass with `MOCK_AUTH=true` +- [ ] Unit test coverage ≥ 90% on `User` entity and application services +- [ ] No `console.log` in committed code — use Pino logger +- [ ] No `any` types in new TypeScript code +- [ ] `npm run build` succeeds with no TypeScript errors +- [ ] Biome lint passes with no errors + +--- + +## 13. Out of Scope + +- Role assignment UI (administrators set roles directly in DB or via the admin API for the demo) +- SSO provider linking UI (Clerk handles OAuth transparently) +- MFA configuration UI +- Profile picture upload (file upload validation and S3 integration deferred) +- Account deletion / GDPR erasure workflow (P3) +- Data portability export (P3) +- Session management UI (session list, revocation) +- Onboarding flow UI (feat-003) +- Full KYC verification flow (feat-007) +- Clerk webhook integration for user sync (lazy sync on first API call is sufficient for the demo) + +--- + +## 14. Implementation Notes and Gotchas + +1. **`requireAuth()` vs `clerkMiddleware()`:** `clerkMiddleware()` must run globally (populates context but does not reject). `requireAuth()` rejects unauthenticated requests. When `MOCK_AUTH=true`, neither should be called — use `MockAuthAdapter` for both. + +2. **Email from JWT claims:** Use `getAuth(req).sessionClaims?.email` (after `clerkMiddleware()` has run) to get the email for upsert. Do not call the Clerk backend API for this — JWT claims are sufficient. + +3. **`roles` array PostgreSQL default:** Use `'{backer}'` (PostgreSQL array literal syntax) as the column default, not `ARRAY['backer']` — the latter is invalid in a `CREATE TABLE` default clause. + +4. **Migration timestamp ordering:** The migration `20260307120000_create_users.sql` must come after `20260305120000_add_updated_at_trigger.sql`. The trigger function `update_updated_at_column()` must exist before the `CREATE TRIGGER` statement runs. + +5. **`super_administrator` database seeding:** For the local demo, Super Administrator accounts are seeded directly in the database. The API enforces that `super_administrator` cannot be assigned through `POST /v1/admin/users/:id/roles`. + +6. **Frontend wildcard routes:** Clerk's `` component handles sub-routes internally. The React Router route must be `/sign-in/*` (with wildcard) to allow Clerk's internal routing to work (e.g., `/sign-in/factor-one`). + +7. **`VITE_` prefix:** Environment variables consumed by Vite's frontend build must be prefixed with `VITE_`. The backend `MOCK_AUTH` and `CLERK_SECRET_KEY` are not exposed to the frontend. + +8. **`onboarding_completed` flag:** This field is set to `false` on user creation and is used by the frontend to determine whether to show the onboarding flow (feat-003). The API does not provide an endpoint to set this flag — that will be added in feat-003. + +9. **Biome `useSemanticElements` rule:** When rendering loading states in `ProtectedRoute`, use `` (which has `role="status"` natively) or `aria-busy` on a block element rather than `
` — Biome will flag the latter. + +10. **`@clerk/express` v1 API only:** Use `clerkMiddleware`, `getAuth`, `requireAuth` from `@clerk/express`. Do not use the deprecated `ClerkExpressRequireAuth` from v0. diff --git a/.claude/prds/feat-002-validation.md b/.claude/prds/feat-002-validation.md new file mode 100644 index 0000000..e2d0798 --- /dev/null +++ b/.claude/prds/feat-002-validation.md @@ -0,0 +1,82 @@ +# feat-002: Validation Report + +**Verdict:** PASS + +--- + +## Summary + +The feat-002 technical spec and design spec are well-constructed and consistent with the PRD, research file, and all referenced authoritative specs (L2-002, L3-002, L3-008, L4-001). The hexagonal architecture is correctly applied, security invariants from L2-002 and L3-002 are honoured, and the design spec handles the Clerk appearance token-mapping exception correctly and explicitly. One minor gap and two warnings are noted but none are blocking. + +--- + +## Checks Passed + +### Technical Spec + +- `clerk_id` never returned in API responses — Section 7.3 explicitly states: "`clerk_id` is NEVER returned in any API response. It is an internal join key only." The `UserProfile` response shape omits it. +- Parameterised queries in all SQL — Section 5.3 `UserRepositoryPg` specifies `$1, $2` parameterised queries throughout, and Section 5.3 "Constraints" explicitly prohibits string interpolation. Migration SQL uses no dynamic construction. +- Auth context extracted from middleware, never from request body — Controller logic in Section 7.2 extracts `clerkUserId` from `authPort.getAuthContext(req)` (which calls `getAuth(req)` from Clerk middleware). No controller reads `user_id` from `req.body`. +- `MOCK_AUTH=true` pattern allows tests without real Clerk — `MockAuthAdapter` is fully specified (Section 5.2), selected in `server.ts` via `process.env.MOCK_AUTH === 'true'` (Section 8), and the integration test suite uses it (Section 10.2). +- JWT validated by Clerk middleware before any route handler — `clerkMiddleware()` runs globally before all routes; `requireAuth()` is applied to the entire `/v1` router group (Section 8). Health check is mounted before auth-protected routes. +- User roles stored in local DB (not in Clerk JWT) — Sections 1, 3.1, and the research file all explicitly state roles live in the local `users.roles` column (TEXT[]). The research file Section RBAC Design states: "Clerk JWT does NOT carry MMF roles". +- `GET /v1/me` creates user on first call (lazy upsert pattern) — `GetOrCreateUserService` (Section 6.1) implements the upsert-on-first-call pattern. The upsert SQL uses `ON CONFLICT (clerk_id) DO UPDATE` (Section 5.3). +- Database migration has both `-- migrate:up` and `-- migrate:down` — Section 2 migration has both sections. Down migration is `DROP TABLE IF EXISTS users`. +- Migration uses `update_updated_at_column()` (already exists) — Section 2 includes a comment: "update_updated_at_column() is defined in 20260305120000_add_updated_at_trigger.sql — Do NOT redefine it here." +- No string interpolation in SQL queries — Section 5.3 explicitly: "Parameterised queries only — no string interpolation in query construction." +- Monetary values (N/A for this feature) — No monetary values in scope for feat-002. +- Acceptance criteria are testable — Section 12 lists 21 discrete, binary-testable acceptance criteria covering authentication, CRUD endpoints, role guards, forbidden actions, and frontend behaviour. +- Error responses use format `{ error: { code, message } }` — Section 7.3 shows error format including `code`, `message`, and `correlation_id`. Section 12 AC confirms this. Consistent with L2-002 Section 5.3 requirement. + +### Design Spec + +- Clerk appearance uses raw values (not CSS vars) — Section 2.1 uses raw hex/rgba throughout. Section 2.2 explicitly explains: "Clerk's `appearance` prop does not consume CSS custom properties — it injects inline styles. This is the only place in the codebase where Tier 1 identity token values are used directly." The exception is documented with a full mapping table. +- All page backgrounds use `--color-bg-page` or approved gradient — Sign In/Sign Up pages use `--gradient-hero`; Loading screen uses `--color-bg-page` (`#060A14`). Both are approved tokens per the colour summary in Section 8. +- Loading state uses `prefers-reduced-motion` safe pattern — Section 5.2 includes `@media (prefers-reduced-motion: reduce)` that disables animation and sets static opacity. Correctly referenced against L2-001 Section 5.2. +- Semantic HTML for loading state (`` not `
`) — Section 5.3 specifies ``. The spec notes that `` has implicit `role="status"` and avoids the Biome `useSemanticElements` lint violation. The `ProtectedRoute` (tech spec Section 9.4) uses `
` — see Warnings below. +- One primary CTA per viewport — Auth pages contain a single Clerk `` or `` component with one primary submit button. No competing primary CTAs in the wrapper layout. + +### Security Checks + +- No user role can be set from user-supplied request body — `AssignRolesService` (Section 6.3) requires the actor to hold `Role.Administrator` before proceeding. The actor identity comes from the auth context (Clerk JWT), not the request body. The `assignRolesSchema` (Section 7.1) validates the roles array but the actor identity is always sourced from the authenticated session. +- `PATCH /v1/me` schema doesn't allow role changes — `patchMeSchema` (Section 7.1) accepts only `display_name`, `bio`, and `avatar_url`. Roles are not in the schema. +- `clerk_id` is internal only (never surfaced to frontend) — Confirmed: the `UserProfile` interface on the frontend (Section 9.3) has no `clerkId` field. The API response shape (Section 7.3) also omits `clerk_id`. +- Auth port interface allows real/mock swap without changing app code — `AuthPort` interface (Section 4.2) is implemented by both `ClerkAuthAdapter` and `MockAuthAdapter`. `server.ts` selects the implementation at startup via env var (Section 8). Application services depend only on the `AuthPort` interface. + +### Cross-spec Compliance + +- Hex architecture maintained — Domain layer (`User`, `Role`, `KycStatus`, domain errors) imports nothing from infrastructure. Ports are interfaces only. Adapters (`UserRepositoryPg`, `ClerkAuthAdapter`, `MockAuthAdapter`) implement port interfaces and are the only layer touching `pg` or `@clerk/express`. Application services inject port interfaces only. +- Domain errors extend DomainError — All five domain errors (`UserNotFoundError`, `UserAlreadyExistsError`, `RoleAssignmentForbiddenError`, `InvalidRoleError`, `SuperAdminAssignmentRestrictedError`, `UserValidationError`) extend `DomainError` with unique `code` values (Section 3.4). Matches L4-001 requirement and CLAUDE.md backend rules. +- Frontend API client injects JWT from Clerk — `createApiClient` (Section 9.2) accepts a `getToken` function and injects it as `Authorization: Bearer `. `useCurrentUser` (Section 9.3) passes `getToken` from Clerk's `useAuth()`. + +--- + +## Failures (Must Fix) + +None. No hard failures were identified. + +--- + +## Warnings + +**[WARN]** `ProtectedRoute` loading state uses `
` (tech spec Section 9.4), but the design spec (Section 5.3) specifies `` for `AuthLoadingScreen.tsx`. These are different components — `ProtectedRoute` renders an inline `
` placeholder while `AuthLoadingScreen` is the dedicated full-page loading screen. The inline `
` in `ProtectedRoute` is unlikely to trigger a Biome `useSemanticElements` violation since it does not set `role="status"` explicitly. However, the implementation agent should verify that Biome does not flag this and, if it does, use `` or an `aria-live` region instead. No change required to the spec, but the implementation agent needs to be aware. + +**[WARN]** `AssignRolesService` (Section 6.3) checks only `Role.Administrator` for the actor guard: `if (!actorUser.hasRole(Role.Administrator))`. Per L3-002 Section 5.2 and L4-001 Section 3.1, the `super_administrator` role also has the capability to assign roles. An actor who is `super_administrator` but not `administrator` will be incorrectly rejected. The spec should check `actorUser.hasRole(Role.Administrator) || actorUser.hasRole(Role.SuperAdministrator)` — or equivalently use `actorUser.isAdmin()` (which already returns true for all three admin-tier roles including `super_administrator`). Using `actorUser.isAdmin()` would resolve this more cleanly. + +--- + +## Notes for Implementation + +1. **`AssignRolesService` actor guard**: Use `actorUser.isAdmin()` instead of `actorUser.hasRole(Role.Administrator)` to correctly permit Super Administrators to assign roles. This aligns with L4-001 Section 3.1 and L3-002 Section 5.1 which give Administrator capabilities to Super Administrators. Alternatively, check both roles explicitly. + +2. **`ProtectedRoute` vs `AuthLoadingScreen`**: The design spec separates these into two distinct components — `ProtectedRoute` renders the inline loading state (the `
` stub in the tech spec), and `AuthLoadingScreen` is the full-page branded loading screen that should replace the bare `
`. The implementation agent should render `` inside `ProtectedRoute` when `!isLoaded`, not a raw `
`. The design spec Section 4.3 explicitly states: "While Clerk is initialising, render the full-page loading state (see Section 5)." + +3. **`updateProfile` dynamic SET clause**: The spec notes that `updateProfile()` builds a "dynamic SET clause from provided fields using parameterised values." This is the one place in the repository where a query is partially dynamic. The implementation must use an array-accumulation pattern (push field names and values, build the SET string from the names, pass the values as params array) — never concatenate user-supplied field values into the SQL string. Only field names (which are developer-controlled constants, not user input) are interpolated. + +4. **`getAuth(req).sessionClaims?.email` type cast**: The controller logic (Section 7.2) notes casting to `string` with a fallback to empty string. Clerk guarantees email is present for verified accounts, but the cast is necessary because `sessionClaims` is typed as `Record`. A missing email should result in a `UserValidationError` (via `User.create()`), not a silent empty-string user — the fallback to empty string in the controller means `User.create()` will return `Result.fail` due to invalid email format, which is the correct behaviour. + +5. **Migration timestamp**: The spec uses `20260307120000_create_users.sql`. The implementation agent must confirm this is later than all existing migrations (the latest known is `20260305120000`) before applying. + +6. **`VITE_API_BASE_URL` addition to `.env.example`**: The spec notes this variable needs to be added to `.env.example`. The implementation agent should verify it is not already present before adding. + +7. **Test coverage gate**: The spec requires unit test coverage ≥ 90% on the `User` entity and application services. The `User.create()` method body is left as a comment block in the spec — the implementation agent must implement the actual validation logic (non-empty `clerkId`, basic email format check) and ensure the six test cases listed in Section 10.1 achieve this threshold. diff --git a/.env.example b/.env.example index dfe96d3..96bdf13 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ LOG_LEVEL=debug # Frontend VITE_API_URL=http://localhost:3001 +VITE_API_BASE_URL=http://localhost:3001 # Authentication (Clerk) CLERK_PUBLISHABLE_KEY=pk_test_placeholder diff --git a/db/migrations/20260307120000_create_users.sql b/db/migrations/20260307120000_create_users.sql new file mode 100644 index 0000000..efb71b8 --- /dev/null +++ b/db/migrations/20260307120000_create_users.sql @@ -0,0 +1,32 @@ +-- migrate:up + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clerk_id VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + display_name VARCHAR(255), + avatar_url VARCHAR(500), + bio TEXT, + roles TEXT[] NOT NULL DEFAULT '{backer}', + kyc_status VARCHAR(50) NOT NULL DEFAULT 'not_verified', + onboarding_completed BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_users_kyc_status CHECK ( + kyc_status IN ('not_verified', 'pending', 'in_review', 'verified', 'failed', 'expired') + ) +); + +CREATE INDEX idx_users_clerk_id ON users (clerk_id); +CREATE INDEX idx_users_email ON users (email); + +-- update_updated_at_column() is defined in 20260305120000_add_updated_at_trigger.sql +-- Do NOT redefine it here. +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- migrate:down + +DROP TABLE IF EXISTS users; diff --git a/package-lock.json b/package-lock.json index 3d97198..d1cd4f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -587,20 +587,59 @@ } }, "node_modules/@clerk/react": { - "version": "5.54.0", - "resolved": "https://registry.npmjs.org/@clerk/react/-/react-5.54.0.tgz", - "integrity": "sha512-Le6F2a3jF0dlFU1w7MI53bvrvOTWp+8Tp85OkVIjws0bym3UvvXZFhbcI9Kk6rF4Xwd/qpZe9dJP70WlaB4TOQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.0.1.tgz", + "integrity": "sha512-zq6vfH7Yiul4PPxUmyl/iW6CZbIzxfHvhXLxZK9zbqHQEy9xDlIQirhWIiHucBqMWTJruudY5KCV5WBe2xmfww==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.33.0", + "@clerk/shared": "^4.0.0", "tslib": "2.8.1" }, "engines": { "node": ">=20.9.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + } + }, + "node_modules/@clerk/react/node_modules/@clerk/shared": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.0.0.tgz", + "integrity": "sha512-Z3QhVud7FM9SBgSGxyUdC+nDg6vro+5zJ5gDO1To3FDzRLWKW4xIGd5y8UBqWZMMMHWaSDiZvYlUynb+gs8PnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0" + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/react/node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@clerk/shared": { @@ -9138,7 +9177,7 @@ "packages/frontend": { "name": "@mmf/frontend", "dependencies": { - "@clerk/react": "^5.0.0", + "@clerk/react": "^6.0.0", "@tanstack/react-query": "^5.0.0", "posthog-js": "^1.0.0", "react": "^19.0.0", diff --git a/packages/backend/src/account/adapters/UserRepositoryPg.ts b/packages/backend/src/account/adapters/UserRepositoryPg.ts new file mode 100644 index 0000000..b83cf23 --- /dev/null +++ b/packages/backend/src/account/adapters/UserRepositoryPg.ts @@ -0,0 +1,113 @@ +import type { Pool } from 'pg'; +import type { KycStatus } from '../domain/KycStatus'; +import type { Role } from '../domain/Role'; +import { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; + +export class UserRepositoryPg implements UserRepository { + constructor(private readonly pool: Pool) {} + + async findByClerkId(clerkId: string): Promise { + const result = await this.pool.query('SELECT * FROM users WHERE clerk_id = $1', [clerkId]); + if (result.rows.length === 0) return null; + return this.mapRow(result.rows[0]); + } + + async findById(id: string): Promise { + const result = await this.pool.query('SELECT * FROM users WHERE id = $1', [id]); + if (result.rows.length === 0) return null; + return this.mapRow(result.rows[0]); + } + + async upsert(user: User): Promise { + const result = await this.pool.query( + `INSERT INTO users ( + id, clerk_id, email, display_name, avatar_url, bio, roles, + kyc_status, onboarding_completed, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (clerk_id) DO UPDATE SET + email = EXCLUDED.email, + updated_at = NOW() + RETURNING *`, + [ + user.id, + user.clerkId, + user.email, + user.displayName, + user.avatarUrl, + user.bio, + user.roles, + user.kycStatus, + user.onboardingCompleted, + user.createdAt, + user.updatedAt, + ], + ); + return this.mapRow(result.rows[0]); + } + + async updateProfile( + id: string, + fields: { displayName?: string | null; bio?: string | null; avatarUrl?: string | null }, + ): Promise { + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if ('displayName' in fields) { + setClauses.push(`display_name = $${paramIndex}`); + values.push(fields.displayName); + paramIndex++; + } + if ('bio' in fields) { + setClauses.push(`bio = $${paramIndex}`); + values.push(fields.bio); + paramIndex++; + } + if ('avatarUrl' in fields) { + setClauses.push(`avatar_url = $${paramIndex}`); + values.push(fields.avatarUrl); + paramIndex++; + } + + if (setClauses.length === 0) { + return this.findById(id); + } + + setClauses.push(`updated_at = NOW()`); + values.push(id); + + const result = await this.pool.query( + `UPDATE users SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + values, + ); + + if (result.rows.length === 0) return null; + return this.mapRow(result.rows[0]); + } + + async updateRoles(id: string, roles: string[]): Promise { + const result = await this.pool.query( + 'UPDATE users SET roles = $1, updated_at = NOW() WHERE id = $2 RETURNING *', + [roles, id], + ); + if (result.rows.length === 0) return null; + return this.mapRow(result.rows[0]); + } + + private mapRow(row: Record): User { + return User.reconstitute({ + id: row.id as string, + clerkId: row.clerk_id as string, + email: row.email as string, + displayName: (row.display_name as string | null) ?? null, + avatarUrl: (row.avatar_url as string | null) ?? null, + bio: (row.bio as string | null) ?? null, + roles: row.roles as Role[], + kycStatus: row.kyc_status as KycStatus, + onboardingCompleted: row.onboarding_completed as boolean, + createdAt: row.created_at as Date, + updatedAt: row.updated_at as Date, + }); + } +} diff --git a/packages/backend/src/account/api/account.router.test.ts b/packages/backend/src/account/api/account.router.test.ts new file mode 100644 index 0000000..aabb71a --- /dev/null +++ b/packages/backend/src/account/api/account.router.test.ts @@ -0,0 +1,289 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; +import { MOCK_CLERK_USER_ID, MockAuthAdapter } from '../../shared/adapters/auth/MockAuthAdapter'; +import type { AssignRolesService } from '../application/AssignRolesService'; +import type { GetOrCreateUserService } from '../application/GetOrCreateUserService'; +import type { UpdateUserProfileService } from '../application/UpdateUserProfileService'; +import { KycStatus } from '../domain/KycStatus'; +import { Role } from '../domain/Role'; +import { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; +import { createAccountRouter } from './account.router'; + +// A fixed user returned by most mocks +function makeUser( + overrides: Partial<{ + id: string; + clerkId: string; + email: string; + roles: Role[]; + }> = {}, +): User { + return User.reconstitute({ + id: overrides.id ?? 'user-uuid-001', + clerkId: overrides.clerkId ?? MOCK_CLERK_USER_ID, + email: overrides.email ?? 'astronaut@mars.example', + displayName: 'Astro Naut', + avatarUrl: null, + bio: null, + roles: overrides.roles ?? [Role.Backer], + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: new Date('2026-03-07T00:00:00Z'), + updatedAt: new Date('2026-03-07T00:00:00Z'), + }); +} + +function buildTestApp( + userRepo: UserRepository, + getOrCreateSvc: Pick, + updateProfileSvc: Pick, + assignRolesSvc: Pick, +) { + const authAdapter = new MockAuthAdapter(); + const app = express(); + app.use(express.json()); + app.use(authAdapter.globalMiddleware()); + + const router = createAccountRouter({ + authAdapter, + userRepo, + getOrCreateUserService: getOrCreateSvc as GetOrCreateUserService, + updateUserProfileService: updateProfileSvc as UpdateUserProfileService, + assignRolesService: assignRolesSvc as AssignRolesService, + }); + + const v1 = express.Router(); + v1.use(authAdapter.requireAuthMiddleware()); + v1.use(router); + app.use('/v1', v1); + + return app; +} + +function makeDefaultMocks() { + const user = makeUser(); + const userRepo: UserRepository = { + findByClerkId: vi.fn().mockResolvedValue(user), + findById: vi.fn().mockResolvedValue(user), + upsert: vi.fn().mockResolvedValue(user), + updateProfile: vi.fn().mockResolvedValue(user), + updateRoles: vi.fn().mockResolvedValue(user), + }; + const getOrCreateSvc: Pick = { + execute: vi.fn().mockResolvedValue(user), + }; + const updateProfileSvc: Pick = { + execute: vi.fn().mockResolvedValue(user), + }; + const assignRolesSvc: Pick = { + execute: vi.fn().mockResolvedValue(user), + }; + return { user, userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc }; +} + +describe('GET /v1/me', () => { + it('creates user record on first call and returns profile without clerk_id', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc } = makeDefaultMocks(); + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app).get('/v1/me'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('id'); + expect(res.body).toHaveProperty('email'); + expect(res.body).toHaveProperty('roles'); + expect(res.body).toHaveProperty('kycStatus'); + expect(res.body).toHaveProperty('onboardingCompleted'); + // clerk_id must NEVER appear in the response + expect(res.body).not.toHaveProperty('clerkId'); + expect(res.body).not.toHaveProperty('clerk_id'); + }); + + it('calls getOrCreateUserService.execute with mock clerk user id', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc } = makeDefaultMocks(); + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + await request(app).get('/v1/me'); + + expect(getOrCreateSvc.execute).toHaveBeenCalledWith(MOCK_CLERK_USER_ID, expect.any(String)); + }); + + it('returns 200 with user data', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc, user } = makeDefaultMocks(); + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app).get('/v1/me'); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(user.id); + expect(res.body.email).toBe(user.email); + expect(res.body.roles).toEqual([Role.Backer]); + expect(res.body.kycStatus).toBe(KycStatus.NotVerified); + expect(res.body.onboardingCompleted).toBe(false); + }); +}); + +describe('PATCH /v1/me', () => { + it('updates profile with valid body and returns 200', async () => { + const updatedUser = makeUser(); + const { userRepo, getOrCreateSvc, assignRolesSvc } = makeDefaultMocks(); + const updateProfileSvc: Pick = { + execute: vi.fn().mockResolvedValue( + User.reconstitute({ + ...updatedUser, + displayName: 'New Name', + bio: 'New bio', + }), + ), + }; + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app) + .patch('/v1/me') + .send({ display_name: 'New Name', bio: 'New bio' }); + + expect(res.status).toBe(200); + expect(res.body).not.toHaveProperty('clerk_id'); + expect(res.body).not.toHaveProperty('clerkId'); + }); + + it('returns 400 when avatar_url is not a valid URL', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc } = makeDefaultMocks(); + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app).patch('/v1/me').send({ avatar_url: 'not-a-url' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('returns 404 when user is not found in repo', async () => { + const { getOrCreateSvc, updateProfileSvc, assignRolesSvc } = makeDefaultMocks(); + const userRepo: UserRepository = { + findByClerkId: vi.fn().mockResolvedValue(null), + findById: vi.fn().mockResolvedValue(null), + upsert: vi.fn(), + updateProfile: vi.fn().mockResolvedValue(null), + updateRoles: vi.fn().mockResolvedValue(null), + }; + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app).patch('/v1/me').send({ display_name: 'Test' }); + + expect(res.status).toBe(404); + }); +}); + +describe('GET /v1/me/roles', () => { + it('returns the user roles array', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc } = makeDefaultMocks(); + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app).get('/v1/me/roles'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('roles'); + expect(Array.isArray(res.body.roles)).toBe(true); + }); +}); + +describe('POST /v1/admin/users/:id/roles', () => { + it('returns 403 when actor is a non-administrator (Backer)', async () => { + const backerUser = makeUser({ roles: [Role.Backer] }); + const { userRepo, getOrCreateSvc, updateProfileSvc } = makeDefaultMocks(); + // Patch repo to return backer user + const repoWithBacker: UserRepository = { + ...userRepo, + findByClerkId: vi.fn().mockResolvedValue(backerUser), + }; + + // Use a service that throws the actual domain error + const { RoleAssignmentForbiddenError } = await import( + '../domain/errors/RoleAssignmentForbiddenError' + ); + const realAssignRolesSvc: Pick = { + execute: vi.fn().mockRejectedValue(new RoleAssignmentForbiddenError()), + }; + + const app = buildTestApp(repoWithBacker, getOrCreateSvc, updateProfileSvc, realAssignRolesSvc); + + const res = await request(app) + .post('/v1/admin/users/target-uuid/roles') + .send({ roles: [Role.Creator] }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('ROLE_ASSIGNMENT_FORBIDDEN'); + }); + + it('returns 403 when super_administrator is in roles list', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc } = makeDefaultMocks(); + const { SuperAdminAssignmentRestrictedError } = await import( + '../domain/errors/SuperAdminAssignmentRestrictedError' + ); + const assignRolesSvc: Pick = { + execute: vi.fn().mockRejectedValue(new SuperAdminAssignmentRestrictedError()), + }; + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app) + .post('/v1/admin/users/target-uuid/roles') + .send({ roles: [Role.SuperAdministrator] }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('SUPER_ADMIN_ASSIGNMENT_RESTRICTED'); + }); + + it('returns 400 when unknown role is provided', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc } = makeDefaultMocks(); + const { InvalidRoleError } = await import('../domain/errors/InvalidRoleError'); + const assignRolesSvc: Pick = { + execute: vi.fn().mockRejectedValue(new InvalidRoleError('dragon_master')), + }; + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app) + .post('/v1/admin/users/target-uuid/roles') + .send({ roles: ['dragon_master'] }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('INVALID_ROLE'); + }); + + it('returns 400 when roles array is empty', async () => { + const { userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc } = makeDefaultMocks(); + const app = buildTestApp(userRepo, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app).post('/v1/admin/users/target-uuid/roles').send({ roles: [] }); + + expect(res.status).toBe(400); + }); + + it('returns 200 with updated user when Administrator assigns valid roles', async () => { + const adminUser = makeUser({ roles: [Role.Administrator] }); + const updatedTarget = makeUser({ id: 'target-uuid', roles: [Role.Creator] }); + + const repoWithAdmin: UserRepository = { + findByClerkId: vi.fn().mockResolvedValue(adminUser), + findById: vi.fn().mockResolvedValue(updatedTarget), + upsert: vi.fn(), + updateProfile: vi.fn().mockResolvedValue(null), + updateRoles: vi.fn().mockResolvedValue(updatedTarget), + }; + const { getOrCreateSvc, updateProfileSvc } = makeDefaultMocks(); + const assignRolesSvc: Pick = { + execute: vi.fn().mockResolvedValue(updatedTarget), + }; + + const app = buildTestApp(repoWithAdmin, getOrCreateSvc, updateProfileSvc, assignRolesSvc); + + const res = await request(app) + .post('/v1/admin/users/target-uuid/roles') + .send({ roles: [Role.Creator] }); + + expect(res.status).toBe(200); + expect(res.body).not.toHaveProperty('clerk_id'); + expect(res.body).not.toHaveProperty('clerkId'); + }); +}); diff --git a/packages/backend/src/account/api/account.router.ts b/packages/backend/src/account/api/account.router.ts new file mode 100644 index 0000000..56a10b3 --- /dev/null +++ b/packages/backend/src/account/api/account.router.ts @@ -0,0 +1,246 @@ +import express, { type Request, type Response } from 'express'; +import pino from 'pino'; +import { DomainError } from '../../shared/domain/errors/DomainError'; +import type { AuthPort } from '../../shared/ports/AuthPort'; +import type { AssignRolesService } from '../application/AssignRolesService'; +import type { GetOrCreateUserService } from '../application/GetOrCreateUserService'; +import type { UpdateUserProfileService } from '../application/UpdateUserProfileService'; +import type { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; +import { assignRolesSchema, patchMeSchema } from './account.schemas'; + +const logger = pino({ + level: process.env.LOG_LEVEL ?? 'info', + transport: + process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +const DOMAIN_ERROR_STATUS: Record = { + USER_NOT_FOUND: 404, + USER_ALREADY_EXISTS: 409, + ROLE_ASSIGNMENT_FORBIDDEN: 403, + INVALID_ROLE: 400, + SUPER_ADMIN_ASSIGNMENT_RESTRICTED: 403, + USER_VALIDATION_ERROR: 400, +}; + +function serializeUser(user: User) { + return { + id: user.id, + email: user.email, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + bio: user.bio, + roles: user.roles, + kycStatus: user.kycStatus, + onboardingCompleted: user.onboardingCompleted, + }; +} + +function handleError(res: Response, err: unknown, correlationId: string) { + if (err instanceof DomainError) { + const status = DOMAIN_ERROR_STATUS[err.code] ?? 500; + return res.status(status).json({ + error: { code: err.code, message: err.message, correlation_id: correlationId }, + }); + } + logger.error({ err, correlation_id: correlationId }, 'Unhandled error in account router'); + return res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'An unexpected error occurred.', + correlation_id: correlationId, + }, + }); +} + +export interface AccountRouterDeps { + authAdapter: AuthPort; + userRepo: UserRepository; + getOrCreateUserService: GetOrCreateUserService; + updateUserProfileService: UpdateUserProfileService; + assignRolesService: AssignRolesService; +} + +export function createAccountRouter(deps: AccountRouterDeps) { + const { + authAdapter, + userRepo, + getOrCreateUserService, + updateUserProfileService, + assignRolesService, + } = deps; + + const router = express.Router(); + + // GET /me — get or create authenticated user profile + router.get('/me', async (req: Request, res: Response) => { + const correlationId = (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID(); + try { + const authContext = authAdapter.getAuthContext(req); + if (!authContext) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required.', + correlation_id: correlationId, + }, + }); + } + + // Extract email from Clerk session claims (populated by clerkMiddleware) + // For mock auth, fall back to a placeholder that passes validation + const reqAny = req as Request & { auth?: { sessionClaims?: { email?: string } } }; + const email = (reqAny.auth?.sessionClaims?.email as string | undefined) ?? 'mock@example.com'; + + const user = await getOrCreateUserService.execute(authContext.clerkUserId, email); + return res.status(200).json(serializeUser(user)); + } catch (err) { + return handleError(res, err, correlationId); + } + }); + + // PATCH /me — update profile fields + router.patch('/me', async (req: Request, res: Response) => { + const correlationId = (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID(); + try { + const authContext = authAdapter.getAuthContext(req); + if (!authContext) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required.', + correlation_id: correlationId, + }, + }); + } + + const parseResult = patchMeSchema.safeParse(req.body); + if (!parseResult.success) { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: parseResult.error.errors.map((e) => e.message).join(', '), + correlation_id: correlationId, + }, + }); + } + + const existingUser = await userRepo.findByClerkId(authContext.clerkUserId); + if (!existingUser) { + return res.status(404).json({ + error: { + code: 'USER_NOT_FOUND', + message: 'User not found.', + correlation_id: correlationId, + }, + }); + } + + const body = parseResult.data; + const updated = await updateUserProfileService.execute(existingUser.id, { + displayName: body.display_name, + bio: body.bio, + avatarUrl: body.avatar_url, + }); + + return res.status(200).json(serializeUser(updated)); + } catch (err) { + return handleError(res, err, correlationId); + } + }); + + // GET /me/roles — get current user's roles + router.get('/me/roles', async (req: Request, res: Response) => { + const correlationId = (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID(); + try { + const authContext = authAdapter.getAuthContext(req); + if (!authContext) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required.', + correlation_id: correlationId, + }, + }); + } + + const user = await userRepo.findByClerkId(authContext.clerkUserId); + if (!user) { + return res.status(404).json({ + error: { + code: 'USER_NOT_FOUND', + message: 'User not found.', + correlation_id: correlationId, + }, + }); + } + + return res.status(200).json({ roles: user.roles }); + } catch (err) { + return handleError(res, err, correlationId); + } + }); + + // POST /admin/users/:id/roles — assign roles (Administrator only) + router.post('/admin/users/:id/roles', async (req: Request, res: Response) => { + const correlationId = (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID(); + try { + const authContext = authAdapter.getAuthContext(req); + if (!authContext) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required.', + correlation_id: correlationId, + }, + }); + } + + const parseResult = assignRolesSchema.safeParse(req.body); + if (!parseResult.success) { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: parseResult.error.errors.map((e) => e.message).join(', '), + correlation_id: correlationId, + }, + }); + } + + const actorUser = await userRepo.findByClerkId(authContext.clerkUserId); + if (!actorUser) { + return res.status(404).json({ + error: { + code: 'USER_NOT_FOUND', + message: 'User not found.', + correlation_id: correlationId, + }, + }); + } + + const targetUserId = req.params.id as string; + const { roles } = parseResult.data; + + const updatedTarget = await assignRolesService.execute(actorUser, targetUserId, roles); + + logger.info( + { + actor_id: actorUser.id, + target_id: targetUserId, + new_roles: roles, + timestamp: new Date().toISOString(), + }, + 'Role assignment performed', + ); + + return res.status(200).json(serializeUser(updatedTarget)); + } catch (err) { + return handleError(res, err, correlationId); + } + }); + + return router; +} diff --git a/packages/backend/src/account/api/account.schemas.ts b/packages/backend/src/account/api/account.schemas.ts new file mode 100644 index 0000000..054ebde --- /dev/null +++ b/packages/backend/src/account/api/account.schemas.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const patchMeSchema = z.object({ + display_name: z.string().max(255).nullable().optional(), + bio: z.string().nullable().optional(), + avatar_url: z.string().url().max(500).nullable().optional(), +}); + +export type PatchMeBody = z.infer; + +export const assignRolesSchema = z.object({ + roles: z.array(z.string()).min(1), +}); + +export type AssignRolesBody = z.infer; diff --git a/packages/backend/src/account/application/AssignRolesService.test.ts b/packages/backend/src/account/application/AssignRolesService.test.ts new file mode 100644 index 0000000..0f099d6 --- /dev/null +++ b/packages/backend/src/account/application/AssignRolesService.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InvalidRoleError } from '../domain/errors/InvalidRoleError'; +import { RoleAssignmentForbiddenError } from '../domain/errors/RoleAssignmentForbiddenError'; +import { SuperAdminAssignmentRestrictedError } from '../domain/errors/SuperAdminAssignmentRestrictedError'; +import { UserNotFoundError } from '../domain/errors/UserNotFoundError'; +import { KycStatus } from '../domain/KycStatus'; +import { Role } from '../domain/Role'; +import { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; +import { AssignRolesService } from './AssignRolesService'; + +function makeUser(roles: Role[], id = 'uuid-1'): User { + return User.reconstitute({ + id, + clerkId: 'clerk_1', + email: 'test@example.com', + displayName: null, + avatarUrl: null, + bio: null, + roles, + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +function makeMockRepo(overrides: Partial = {}): UserRepository { + return { + findByClerkId: vi.fn().mockResolvedValue(null), + findById: vi.fn().mockResolvedValue(null), + upsert: vi.fn(), + updateProfile: vi.fn().mockResolvedValue(null), + updateRoles: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +describe('AssignRolesService', () => { + it('throws RoleAssignmentForbiddenError when actor does not have Administrator role', async () => { + const actor = makeUser([Role.Backer]); + const repo = makeMockRepo(); + const service = new AssignRolesService(repo); + + await expect(service.execute(actor, 'target-uuid', [Role.Creator])).rejects.toThrow( + RoleAssignmentForbiddenError, + ); + expect(repo.updateRoles).not.toHaveBeenCalled(); + }); + + it('throws RoleAssignmentForbiddenError when actor is Reviewer but not Administrator', async () => { + const actor = makeUser([Role.Reviewer]); + const repo = makeMockRepo(); + const service = new AssignRolesService(repo); + + await expect(service.execute(actor, 'target-uuid', [Role.Backer])).rejects.toThrow( + RoleAssignmentForbiddenError, + ); + }); + + it('throws InvalidRoleError for an unrecognised role string', async () => { + const actor = makeUser([Role.Administrator]); + const repo = makeMockRepo(); + const service = new AssignRolesService(repo); + + await expect(service.execute(actor, 'target-uuid', ['dragon_master'])).rejects.toThrow( + InvalidRoleError, + ); + }); + + it('throws SuperAdminAssignmentRestrictedError when super_administrator is in roles list', async () => { + const actor = makeUser([Role.Administrator]); + const repo = makeMockRepo(); + const service = new AssignRolesService(repo); + + await expect(service.execute(actor, 'target-uuid', [Role.SuperAdministrator])).rejects.toThrow( + SuperAdminAssignmentRestrictedError, + ); + }); + + it('updates roles successfully when actor is Administrator and roles are valid', async () => { + const actor = makeUser([Role.Administrator]); + const _targetUser = makeUser([Role.Backer], 'target-uuid'); + const updatedUser = makeUser([Role.Creator], 'target-uuid'); + const repo = makeMockRepo({ + updateRoles: vi.fn().mockResolvedValue(updatedUser), + }); + const service = new AssignRolesService(repo); + + const result = await service.execute(actor, 'target-uuid', [Role.Creator]); + + expect(repo.updateRoles).toHaveBeenCalledWith('target-uuid', [Role.Creator]); + expect(result).toBe(updatedUser); + }); + + it('throws UserNotFoundError when target user does not exist', async () => { + const actor = makeUser([Role.Administrator]); + const repo = makeMockRepo({ + updateRoles: vi.fn().mockResolvedValue(null), + }); + const service = new AssignRolesService(repo); + + await expect(service.execute(actor, 'nonexistent-uuid', [Role.Backer])).rejects.toThrow( + UserNotFoundError, + ); + }); +}); diff --git a/packages/backend/src/account/application/AssignRolesService.ts b/packages/backend/src/account/application/AssignRolesService.ts new file mode 100644 index 0000000..9e67c3d --- /dev/null +++ b/packages/backend/src/account/application/AssignRolesService.ts @@ -0,0 +1,35 @@ +import { InvalidRoleError } from '../domain/errors/InvalidRoleError'; +import { RoleAssignmentForbiddenError } from '../domain/errors/RoleAssignmentForbiddenError'; +import { SuperAdminAssignmentRestrictedError } from '../domain/errors/SuperAdminAssignmentRestrictedError'; +import { UserNotFoundError } from '../domain/errors/UserNotFoundError'; +import { ALL_ROLES, Role } from '../domain/Role'; +import type { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; + +export class AssignRolesService { + constructor(private readonly userRepo: UserRepository) {} + + async execute(actorUser: User, targetUserId: string, newRoles: string[]): Promise { + // 1. Actor must have Administrator role + if (!actorUser.hasRole(Role.Administrator)) { + throw new RoleAssignmentForbiddenError(); + } + + // 2. Validate all requested roles are known values + for (const role of newRoles) { + if (!ALL_ROLES.includes(role as Role)) { + throw new InvalidRoleError(role); + } + } + + // 3. Super Administrator cannot be assigned through this endpoint (AC-ACCT-014) + if (newRoles.includes(Role.SuperAdministrator)) { + throw new SuperAdminAssignmentRestrictedError(); + } + + // 4. Apply role update + const updated = await this.userRepo.updateRoles(targetUserId, newRoles); + if (!updated) throw new UserNotFoundError(); + return updated; + } +} diff --git a/packages/backend/src/account/application/GetOrCreateUserService.test.ts b/packages/backend/src/account/application/GetOrCreateUserService.test.ts new file mode 100644 index 0000000..e2747fe --- /dev/null +++ b/packages/backend/src/account/application/GetOrCreateUserService.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { KycStatus } from '../domain/KycStatus'; +import { Role } from '../domain/Role'; +import { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; +import { GetOrCreateUserService } from './GetOrCreateUserService'; + +function makeUser(clerkId: string, email: string): User { + return User.reconstitute({ + id: 'uuid-1', + clerkId, + email, + displayName: null, + avatarUrl: null, + bio: null, + roles: [Role.Backer], + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +function makeMockRepo(overrides: Partial = {}): UserRepository { + return { + findByClerkId: vi.fn().mockResolvedValue(null), + findById: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockImplementation((u: User) => Promise.resolve(u)), + updateProfile: vi.fn().mockResolvedValue(null), + updateRoles: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +describe('GetOrCreateUserService', () => { + it('returns existing user when findByClerkId returns a user', async () => { + const existing = makeUser('clerk_abc', 'a@b.com'); + const repo = makeMockRepo({ findByClerkId: vi.fn().mockResolvedValue(existing) }); + const service = new GetOrCreateUserService(repo); + + const result = await service.execute('clerk_abc', 'a@b.com'); + + expect(result).toBe(existing); + expect(repo.upsert).not.toHaveBeenCalled(); + }); + + it('creates and returns a new user when findByClerkId returns null', async () => { + const upserted = makeUser('clerk_new', 'new@example.com'); + const repo = makeMockRepo({ + findByClerkId: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue(upserted), + }); + const service = new GetOrCreateUserService(repo); + + const result = await service.execute('clerk_new', 'new@example.com'); + + expect(repo.upsert).toHaveBeenCalledOnce(); + expect(result).toBe(upserted); + }); + + it('propagates domain error when User.create() fails (invalid email)', async () => { + const repo = makeMockRepo({ findByClerkId: vi.fn().mockResolvedValue(null) }); + const service = new GetOrCreateUserService(repo); + + await expect(service.execute('clerk_abc', 'not-an-email')).rejects.toThrow(); + expect(repo.upsert).not.toHaveBeenCalled(); + }); + + it('propagates domain error when User.create() fails (empty clerkId)', async () => { + const repo = makeMockRepo({ findByClerkId: vi.fn().mockResolvedValue(null) }); + const service = new GetOrCreateUserService(repo); + + await expect(service.execute('', 'valid@example.com')).rejects.toThrow(); + }); +}); diff --git a/packages/backend/src/account/application/GetOrCreateUserService.ts b/packages/backend/src/account/application/GetOrCreateUserService.ts new file mode 100644 index 0000000..cdfd89f --- /dev/null +++ b/packages/backend/src/account/application/GetOrCreateUserService.ts @@ -0,0 +1,18 @@ +import { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; + +export class GetOrCreateUserService { + constructor(private readonly userRepo: UserRepository) {} + + async execute(clerkId: string, email: string): Promise { + const existing = await this.userRepo.findByClerkId(clerkId); + if (existing) return existing; + + const result = User.create({ clerkId, email }); + if (result.isFailure) { + throw result.error; + } + + return await this.userRepo.upsert(result.value); + } +} diff --git a/packages/backend/src/account/application/UpdateUserProfileService.ts b/packages/backend/src/account/application/UpdateUserProfileService.ts new file mode 100644 index 0000000..2aec57a --- /dev/null +++ b/packages/backend/src/account/application/UpdateUserProfileService.ts @@ -0,0 +1,16 @@ +import { UserNotFoundError } from '../domain/errors/UserNotFoundError'; +import type { User } from '../domain/User'; +import type { UserRepository } from '../ports/UserRepository'; + +export class UpdateUserProfileService { + constructor(private readonly userRepo: UserRepository) {} + + async execute( + userId: string, + fields: { displayName?: string | null; bio?: string | null; avatarUrl?: string | null }, + ): Promise { + const updated = await this.userRepo.updateProfile(userId, fields); + if (!updated) throw new UserNotFoundError(); + return updated; + } +} diff --git a/packages/backend/src/account/domain/KycStatus.ts b/packages/backend/src/account/domain/KycStatus.ts new file mode 100644 index 0000000..706b737 --- /dev/null +++ b/packages/backend/src/account/domain/KycStatus.ts @@ -0,0 +1,10 @@ +export const KycStatus = { + NotVerified: 'not_verified', + Pending: 'pending', + InReview: 'in_review', + Verified: 'verified', + Failed: 'failed', + Expired: 'expired', +} as const; + +export type KycStatus = (typeof KycStatus)[keyof typeof KycStatus]; diff --git a/packages/backend/src/account/domain/Role.ts b/packages/backend/src/account/domain/Role.ts new file mode 100644 index 0000000..f57486a --- /dev/null +++ b/packages/backend/src/account/domain/Role.ts @@ -0,0 +1,13 @@ +export const Role = { + Backer: 'backer', + Creator: 'creator', + Reviewer: 'reviewer', + Administrator: 'administrator', + SuperAdministrator: 'super_administrator', +} as const; + +export type Role = (typeof Role)[keyof typeof Role]; + +export const ALL_ROLES: Role[] = Object.values(Role); + +export const ADMIN_ROLES: Role[] = [Role.Reviewer, Role.Administrator, Role.SuperAdministrator]; diff --git a/packages/backend/src/account/domain/User.test.ts b/packages/backend/src/account/domain/User.test.ts new file mode 100644 index 0000000..92088fa --- /dev/null +++ b/packages/backend/src/account/domain/User.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { KycStatus } from './KycStatus'; +import { Role } from './Role'; +import { User } from './User'; + +describe('User.create()', () => { + it('returns a Result.ok with default backer role, not_verified kyc_status, onboardingCompleted: false', () => { + const result = User.create({ clerkId: 'clerk_abc123', email: 'test@example.com' }); + + expect(result.isSuccess).toBe(true); + const user = result.value; + expect(user.clerkId).toBe('clerk_abc123'); + expect(user.email).toBe('test@example.com'); + expect(user.roles).toEqual([Role.Backer]); + expect(user.kycStatus).toBe(KycStatus.NotVerified); + expect(user.onboardingCompleted).toBe(false); + expect(user.displayName).toBeNull(); + expect(user.avatarUrl).toBeNull(); + expect(user.bio).toBeNull(); + expect(user.id).toBeTruthy(); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + }); + + it('accepts optional displayName, avatarUrl, bio', () => { + const result = User.create({ + clerkId: 'clerk_abc123', + email: 'test@example.com', + displayName: 'Ada Lovelace', + avatarUrl: 'https://example.com/avatar.jpg', + bio: 'Pioneer of computing', + }); + + expect(result.isSuccess).toBe(true); + const user = result.value; + expect(user.displayName).toBe('Ada Lovelace'); + expect(user.avatarUrl).toBe('https://example.com/avatar.jpg'); + expect(user.bio).toBe('Pioneer of computing'); + }); + + it('returns Result.fail when clerkId is empty string', () => { + const result = User.create({ clerkId: '', email: 'test@example.com' }); + expect(result.isFailure).toBe(true); + expect(result.error?.message).toContain('clerkId'); + }); + + it('returns Result.fail when clerkId is whitespace-only', () => { + const result = User.create({ clerkId: ' ', email: 'test@example.com' }); + expect(result.isFailure).toBe(true); + expect(result.error?.message).toContain('clerkId'); + }); + + it('returns Result.fail when email is invalid (no @)', () => { + const result = User.create({ clerkId: 'clerk_abc123', email: 'notanemail' }); + expect(result.isFailure).toBe(true); + expect(result.error?.message).toContain('email'); + }); + + it('returns Result.fail when email is empty string', () => { + const result = User.create({ clerkId: 'clerk_abc123', email: '' }); + expect(result.isFailure).toBe(true); + expect(result.error?.message).toContain('email'); + }); + + it('never throws — always returns a Result', () => { + expect(() => User.create({ clerkId: '', email: '' })).not.toThrow(); + }); +}); + +describe('User.reconstitute()', () => { + it('returns a User without validation — accepts any inputs', () => { + const now = new Date(); + const user = User.reconstitute({ + id: 'some-uuid', + clerkId: 'clerk_xyz', + email: 'user@domain.com', + displayName: 'Test User', + avatarUrl: null, + bio: null, + roles: [Role.Creator, Role.Reviewer], + kycStatus: KycStatus.Verified, + onboardingCompleted: true, + createdAt: now, + updatedAt: now, + }); + + expect(user.id).toBe('some-uuid'); + expect(user.clerkId).toBe('clerk_xyz'); + expect(user.roles).toEqual([Role.Creator, Role.Reviewer]); + expect(user.kycStatus).toBe(KycStatus.Verified); + expect(user.onboardingCompleted).toBe(true); + }); +}); + +describe('User.hasRole()', () => { + it('returns true for an assigned role', () => { + const user = User.reconstitute({ + id: 'id-1', + clerkId: 'clerk_1', + email: 'a@b.com', + displayName: null, + avatarUrl: null, + bio: null, + roles: [Role.Backer, Role.Creator], + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(user.hasRole(Role.Backer)).toBe(true); + expect(user.hasRole(Role.Creator)).toBe(true); + }); + + it('returns false for an unassigned role', () => { + const user = User.reconstitute({ + id: 'id-1', + clerkId: 'clerk_1', + email: 'a@b.com', + displayName: null, + avatarUrl: null, + bio: null, + roles: [Role.Backer], + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(user.hasRole(Role.Administrator)).toBe(false); + expect(user.hasRole(Role.Reviewer)).toBe(false); + }); +}); + +describe('User.isAdmin()', () => { + const makeUser = (roles: (typeof Role)[keyof typeof Role][]) => + User.reconstitute({ + id: 'id-1', + clerkId: 'clerk_1', + email: 'a@b.com', + displayName: null, + avatarUrl: null, + bio: null, + roles, + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + it('returns true for Reviewer', () => { + expect(makeUser([Role.Reviewer]).isAdmin()).toBe(true); + }); + + it('returns true for Administrator', () => { + expect(makeUser([Role.Administrator]).isAdmin()).toBe(true); + }); + + it('returns true for SuperAdministrator', () => { + expect(makeUser([Role.SuperAdministrator]).isAdmin()).toBe(true); + }); + + it('returns false for Backer', () => { + expect(makeUser([Role.Backer]).isAdmin()).toBe(false); + }); + + it('returns false for Creator', () => { + expect(makeUser([Role.Creator]).isAdmin()).toBe(false); + }); +}); diff --git a/packages/backend/src/account/domain/User.ts b/packages/backend/src/account/domain/User.ts new file mode 100644 index 0000000..c6569e5 --- /dev/null +++ b/packages/backend/src/account/domain/User.ts @@ -0,0 +1,96 @@ +import { Result } from '../../shared/domain/Result'; +import { UserValidationError } from './errors/UserValidationError'; +import { KycStatus } from './KycStatus'; +import type { Role } from './Role'; + +export interface UserProps { + id: string; + clerkId: string; + email: string; + displayName: string | null; + avatarUrl: string | null; + bio: string | null; + roles: Role[]; + kycStatus: KycStatus; + onboardingCompleted: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class User { + readonly id: string; + readonly clerkId: string; + readonly email: string; + readonly displayName: string | null; + readonly avatarUrl: string | null; + readonly bio: string | null; + readonly roles: Role[]; + readonly kycStatus: KycStatus; + readonly onboardingCompleted: boolean; + readonly createdAt: Date; + readonly updatedAt: Date; + + private constructor(props: UserProps) { + this.id = props.id; + this.clerkId = props.clerkId; + this.email = props.email; + this.displayName = props.displayName; + this.avatarUrl = props.avatarUrl; + this.bio = props.bio; + this.roles = props.roles; + this.kycStatus = props.kycStatus; + this.onboardingCompleted = props.onboardingCompleted; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; + } + + static create(props: { + clerkId: string; + email: string; + displayName?: string | null; + avatarUrl?: string | null; + bio?: string | null; + }): Result { + if (!props.clerkId || props.clerkId.trim().length === 0) { + return Result.fail(new UserValidationError('clerkId is required and must not be empty.')); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!props.email || !emailRegex.test(props.email)) { + return Result.fail(new UserValidationError('A valid email address is required.')); + } + + const now = new Date(); + const user = new User({ + id: crypto.randomUUID(), + clerkId: props.clerkId.trim(), + email: props.email, + displayName: props.displayName ?? null, + avatarUrl: props.avatarUrl ?? null, + bio: props.bio ?? null, + roles: ['backer'], + kycStatus: KycStatus.NotVerified, + onboardingCompleted: false, + createdAt: now, + updatedAt: now, + }); + + return Result.ok(user); + } + + static reconstitute(props: UserProps): User { + return new User(props); + } + + hasRole(role: Role): boolean { + return this.roles.includes(role); + } + + isAdmin(): boolean { + return ( + this.hasRole('reviewer') || + this.hasRole('administrator') || + this.hasRole('super_administrator') + ); + } +} diff --git a/packages/backend/src/account/domain/errors/InvalidRoleError.ts b/packages/backend/src/account/domain/errors/InvalidRoleError.ts new file mode 100644 index 0000000..536fe52 --- /dev/null +++ b/packages/backend/src/account/domain/errors/InvalidRoleError.ts @@ -0,0 +1,9 @@ +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class InvalidRoleError extends DomainError { + readonly code = 'INVALID_ROLE'; + + constructor(role: string) { + super(`'${role}' is not a valid role.`); + } +} diff --git a/packages/backend/src/account/domain/errors/RoleAssignmentForbiddenError.ts b/packages/backend/src/account/domain/errors/RoleAssignmentForbiddenError.ts new file mode 100644 index 0000000..b7e6e60 --- /dev/null +++ b/packages/backend/src/account/domain/errors/RoleAssignmentForbiddenError.ts @@ -0,0 +1,9 @@ +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class RoleAssignmentForbiddenError extends DomainError { + readonly code = 'ROLE_ASSIGNMENT_FORBIDDEN'; + + constructor() { + super('You do not have permission to assign this role.'); + } +} diff --git a/packages/backend/src/account/domain/errors/SuperAdminAssignmentRestrictedError.ts b/packages/backend/src/account/domain/errors/SuperAdminAssignmentRestrictedError.ts new file mode 100644 index 0000000..8461114 --- /dev/null +++ b/packages/backend/src/account/domain/errors/SuperAdminAssignmentRestrictedError.ts @@ -0,0 +1,9 @@ +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class SuperAdminAssignmentRestrictedError extends DomainError { + readonly code = 'SUPER_ADMIN_ASSIGNMENT_RESTRICTED'; + + constructor() { + super('Super Administrator role cannot be assigned through this endpoint.'); + } +} diff --git a/packages/backend/src/account/domain/errors/UserAlreadyExistsError.ts b/packages/backend/src/account/domain/errors/UserAlreadyExistsError.ts new file mode 100644 index 0000000..3c885ed --- /dev/null +++ b/packages/backend/src/account/domain/errors/UserAlreadyExistsError.ts @@ -0,0 +1,9 @@ +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class UserAlreadyExistsError extends DomainError { + readonly code = 'USER_ALREADY_EXISTS'; + + constructor() { + super('A user with this identity already exists.'); + } +} diff --git a/packages/backend/src/account/domain/errors/UserNotFoundError.ts b/packages/backend/src/account/domain/errors/UserNotFoundError.ts new file mode 100644 index 0000000..b42d02f --- /dev/null +++ b/packages/backend/src/account/domain/errors/UserNotFoundError.ts @@ -0,0 +1,9 @@ +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class UserNotFoundError extends DomainError { + readonly code = 'USER_NOT_FOUND'; + + constructor() { + super('User not found.'); + } +} diff --git a/packages/backend/src/account/domain/errors/UserValidationError.ts b/packages/backend/src/account/domain/errors/UserValidationError.ts new file mode 100644 index 0000000..36cd1ad --- /dev/null +++ b/packages/backend/src/account/domain/errors/UserValidationError.ts @@ -0,0 +1,5 @@ +import { DomainError } from '../../../shared/domain/errors/DomainError'; + +export class UserValidationError extends DomainError { + readonly code = 'USER_VALIDATION_ERROR'; +} diff --git a/packages/backend/src/account/ports/UserRepository.ts b/packages/backend/src/account/ports/UserRepository.ts new file mode 100644 index 0000000..98bc1de --- /dev/null +++ b/packages/backend/src/account/ports/UserRepository.ts @@ -0,0 +1,12 @@ +import type { User } from '../domain/User'; + +export interface UserRepository { + findByClerkId(clerkId: string): Promise; + findById(id: string): Promise; + upsert(user: User): Promise; + updateProfile( + id: string, + fields: { displayName?: string | null; bio?: string | null; avatarUrl?: string | null }, + ): Promise; + updateRoles(id: string, roles: string[]): Promise; +} diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 9d8ffab..7c17959 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -1,7 +1,15 @@ import express from 'express'; import pino from 'pino'; import { pinoHttp } from 'pino-http'; +import { UserRepositoryPg } from './account/adapters/UserRepositoryPg'; +import { createAccountRouter } from './account/api/account.router'; +import { AssignRolesService } from './account/application/AssignRolesService'; +import { GetOrCreateUserService } from './account/application/GetOrCreateUserService'; +import { UpdateUserProfileService } from './account/application/UpdateUserProfileService'; +import { ClerkAuthAdapter } from './shared/adapters/auth/ClerkAuthAdapter'; +import { MockAuthAdapter } from './shared/adapters/auth/MockAuthAdapter'; import { pool } from './shared/adapters/db/pool'; +import type { AuthPort } from './shared/ports/AuthPort'; const logger = pino({ level: process.env.LOG_LEVEL ?? 'info', @@ -11,16 +19,40 @@ const logger = pino({ : undefined, }); +const authAdapter: AuthPort = + process.env.MOCK_AUTH === 'true' ? new MockAuthAdapter() : new ClerkAuthAdapter(); + const app = express(); app.use(pinoHttp({ logger })); app.use(express.json()); +app.use(authAdapter.globalMiddleware()); // Health check — public, no auth (per L2-002 §5.4 exception) app.get('/health', (_req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// Dependency wiring +const userRepo = new UserRepositoryPg(pool); +const getOrCreateUserService = new GetOrCreateUserService(userRepo); +const updateUserProfileService = new UpdateUserProfileService(userRepo); +const assignRolesService = new AssignRolesService(userRepo); + +const accountRouter = createAccountRouter({ + authAdapter, + userRepo, + getOrCreateUserService, + updateUserProfileService, + assignRolesService, +}); + +// Protected /v1 route group — all routes require auth +const v1Router = express.Router(); +v1Router.use(authAdapter.requireAuthMiddleware()); +v1Router.use(accountRouter); +app.use('/v1', v1Router); + const PORT = parseInt(process.env.PORT ?? '3001', 10); const server = app.listen(PORT, () => { diff --git a/packages/backend/src/shared/adapters/auth/ClerkAuthAdapter.ts b/packages/backend/src/shared/adapters/auth/ClerkAuthAdapter.ts new file mode 100644 index 0000000..22ab1c8 --- /dev/null +++ b/packages/backend/src/shared/adapters/auth/ClerkAuthAdapter.ts @@ -0,0 +1,19 @@ +import { clerkMiddleware, getAuth, requireAuth } from '@clerk/express'; +import type { Request, RequestHandler } from 'express'; +import type { AuthContext, AuthPort } from '../../ports/AuthPort'; + +export class ClerkAuthAdapter implements AuthPort { + getAuthContext(req: Request): AuthContext | null { + const auth = getAuth(req); + if (!auth.userId) return null; + return { clerkUserId: auth.userId }; + } + + requireAuthMiddleware(): RequestHandler { + return requireAuth(); + } + + globalMiddleware(): RequestHandler { + return clerkMiddleware(); + } +} diff --git a/packages/backend/src/shared/adapters/auth/MockAuthAdapter.ts b/packages/backend/src/shared/adapters/auth/MockAuthAdapter.ts new file mode 100644 index 0000000..e30613e --- /dev/null +++ b/packages/backend/src/shared/adapters/auth/MockAuthAdapter.ts @@ -0,0 +1,22 @@ +import type { Request, RequestHandler } from 'express'; +import type { AuthContext, AuthPort } from '../../ports/AuthPort'; + +export const MOCK_CLERK_USER_ID = 'mock_user_clerk_id'; + +export class MockAuthAdapter implements AuthPort { + getAuthContext(_req: Request): AuthContext { + return { clerkUserId: MOCK_CLERK_USER_ID }; + } + + requireAuthMiddleware(): RequestHandler { + return (_req, _res, next) => next(); + } + + globalMiddleware(): RequestHandler { + return (req, _res, next) => { + // Attach a compatible auth object so code calling getAuthContext works + (req as Request & { auth: AuthContext }).auth = { clerkUserId: MOCK_CLERK_USER_ID }; + next(); + }; + } +} diff --git a/packages/backend/src/shared/domain/Result.ts b/packages/backend/src/shared/domain/Result.ts new file mode 100644 index 0000000..94ab4ad --- /dev/null +++ b/packages/backend/src/shared/domain/Result.ts @@ -0,0 +1,28 @@ +export class Result { + readonly isSuccess: boolean; + readonly isFailure: boolean; + readonly error: Error | undefined; + private readonly _value: T | undefined; + + private constructor(isSuccess: boolean, error?: Error, value?: T) { + this.isSuccess = isSuccess; + this.isFailure = !isSuccess; + this.error = error; + this._value = value; + } + + get value(): T { + if (!this.isSuccess) { + throw new Error('Cannot access value of a failed Result'); + } + return this._value as T; + } + + static ok(value: T): Result { + return new Result(true, undefined, value); + } + + static fail(error: Error): Result { + return new Result(false, error, undefined); + } +} diff --git a/packages/backend/src/shared/ports/AuthPort.ts b/packages/backend/src/shared/ports/AuthPort.ts new file mode 100644 index 0000000..604efcb --- /dev/null +++ b/packages/backend/src/shared/ports/AuthPort.ts @@ -0,0 +1,24 @@ +import type { Request, RequestHandler } from 'express'; + +export interface AuthContext { + clerkUserId: string; +} + +export interface AuthPort { + /** + * Extracts auth context from an Express request after middleware has run. + * Returns null if not authenticated. + */ + getAuthContext(req: Request): AuthContext | null; + + /** + * Returns an Express middleware that rejects unauthenticated requests with 401. + */ + requireAuthMiddleware(): RequestHandler; + + /** + * Returns an Express middleware that populates auth context on the request. + * Does NOT reject unauthenticated requests. + */ + globalMiddleware(): RequestHandler; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c59205f..f284d3c 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -11,7 +11,7 @@ "test:watch": "vitest" }, "dependencies": { - "@clerk/react": "^5.0.0", + "@clerk/react": "^6.0.0", "@tanstack/react-query": "^5.0.0", "posthog-js": "^1.0.0", "react": "^19.0.0", diff --git a/packages/frontend/src/App.test.tsx b/packages/frontend/src/App.test.tsx index 6d738e4..798e020 100644 --- a/packages/frontend/src/App.test.tsx +++ b/packages/frontend/src/App.test.tsx @@ -1,9 +1,19 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import App from './App'; +vi.mock('@clerk/react', () => ({ + useAuth: vi.fn(() => ({ + isLoaded: true, + isSignedIn: true, + })), + ClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + SignIn: () =>
SignIn
, + SignUp: () =>
SignUp
, +})); + describe('App', () => { - it('renders the home page heading', () => { + it('renders the home page heading when authenticated', () => { render(); expect(screen.getByRole('heading', { name: /mars mission fund/i })).toBeInTheDocument(); }); diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 2da9ab9..e076779 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,12 +1,44 @@ -import { BrowserRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router'; +import { ProtectedRoute } from './components/auth/ProtectedRoute'; +import DashboardPage from './pages/DashboardPage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; import HomePage from './routes/HomePage'; +const queryClient = new QueryClient(); + export default function App() { return ( - - - } /> - - + + + + {/* Public routes */} + } /> + } /> + + {/* Protected routes */} + + + + } + /> + + + + } + /> + + {/* Root redirect */} + } /> + + + ); } diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts new file mode 100644 index 0000000..dd7dcb9 --- /dev/null +++ b/packages/frontend/src/api/client.ts @@ -0,0 +1,51 @@ +export interface ApiClient { + get(path: string): Promise; + patch(path: string, body: unknown): Promise; + post(path: string, body: unknown): Promise; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string | undefined, + message: string | undefined, + ) { + super(message ?? `HTTP ${status}`); + this.name = 'ApiError'; + } +} + +export function createApiClient(getToken: () => Promise): ApiClient { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; + + async function request(path: string, init: RequestInit = {}): Promise { + const token = await getToken(); + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init.headers as Record | undefined), + }, + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw new ApiError( + response.status, + errorBody?.error?.code as string | undefined, + errorBody?.error?.message as string | undefined, + ); + } + + return response.json() as Promise; + } + + return { + get: (path: string) => request(path), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + post: (path: string, body: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + }; +} diff --git a/packages/frontend/src/components/auth/AuthLoadingScreen.tsx b/packages/frontend/src/components/auth/AuthLoadingScreen.tsx new file mode 100644 index 0000000..3834253 --- /dev/null +++ b/packages/frontend/src/components/auth/AuthLoadingScreen.tsx @@ -0,0 +1,45 @@ +export function AuthLoadingScreen() { + return ( + <> + + +