diff --git a/.claude/agents/auditor.md b/.claude/agents/auditor.md index 432c11d..0c97270 100644 --- a/.claude/agents/auditor.md +++ b/.claude/agents/auditor.md @@ -351,4 +351,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 5. Write audit report 6. Self-check: did you actually run the tests? Did you check every import? Did you verify parameterised queries? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/cicd-devops.md b/.claude/agents/cicd-devops.md index 15e5df3..4fd286b 100644 --- a/.claude/agents/cicd-devops.md +++ b/.claude/agents/cicd-devops.md @@ -467,4 +467,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 3. Verify pipeline runs successfully (or simulate locally) 4. Self-check: does every test suite run in CI? Is coverage checked? Are artifacts uploaded on failure? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/design-speccer.md b/.claude/agents/design-speccer.md index b4220ae..824253d 100644 --- a/.claude/agents/design-speccer.md +++ b/.claude/agents/design-speccer.md @@ -375,4 +375,4 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Verify all states are defined and accessibility requirements met 5. Self-check: would the Frontend Engineer have any visual ambiguity? Is every pixel justified? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. diff --git a/.claude/agents/frontend-engineer.md b/.claude/agents/frontend-engineer.md index b5da3ae..0daa0d2 100644 --- a/.claude/agents/frontend-engineer.md +++ b/.claude/agents/frontend-engineer.md @@ -426,4 +426,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Run `npm run build` β€” fix any TypeScript errors 5. Self-check: does the implementation match the design spec exactly? Are all states handled? Are tests comprehensive? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/infra-engineer.md b/.claude/agents/infra-engineer.md index 8b7ca95..7f872b5 100644 --- a/.claude/agents/infra-engineer.md +++ b/.claude/agents/infra-engineer.md @@ -48,9 +48,9 @@ Implement every data model change from the feature spec. The Backend Engineer ma BEGIN; --- ============================================================ +-- -------------------------------------------------------- -- New tables --- ============================================================ +-- -------------------------------------------------------- CREATE TABLE IF NOT EXISTS [table_name] ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -66,24 +66,24 @@ CREATE TABLE IF NOT EXISTS [table_name] ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- ============================================================ +-- -------------------------------------------------------- -- Indexes --- ============================================================ +-- -------------------------------------------------------- CREATE INDEX idx_[table]_user_id ON [table_name](user_id); -- FK indexes -- Query pattern indexes (columns used in WHERE/ORDER BY) --- ============================================================ +-- -------------------------------------------------------- -- Constraints --- ============================================================ +-- -------------------------------------------------------- -- CHECK constraints for domain invariants -- UNIQUE constraints where specified --- ============================================================ +-- -------------------------------------------------------- -- Triggers --- ============================================================ +-- -------------------------------------------------------- -- Auto-update updated_at on row modification CREATE OR REPLACE FUNCTION update_updated_at_column() @@ -422,4 +422,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Run `terraform fmt`, `terraform validate`, `terraform plan` 5. Self-check: are all migrations correct? Is every resource tagged? Are manual tasks documented? Is `.env.example` updated? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/integration-engineer.md b/.claude/agents/integration-engineer.md index e77dc6e..d388a30 100644 --- a/.claude/agents/integration-engineer.md +++ b/.claude/agents/integration-engineer.md @@ -389,4 +389,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 6. Update status files 7. Self-check: do all tests pass? Is the real service actually connected? Are status files updated? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/product-strategist.md b/.claude/agents/product-strategist.md index 708845e..c023918 100644 --- a/.claude/agents/product-strategist.md +++ b/.claude/agents/product-strategist.md @@ -170,4 +170,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Write outputs to `.claude/backlog.md` and `.claude/prds/` 5. Self-check: are all completion criteria met? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index a9b1ffe..4c23ce0 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -292,4 +292,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 5. Document all findings 6. Self-check: did you check every query for data isolation? Every endpoint for auth? Every input for validation? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/spec-researcher.md b/.claude/agents/spec-researcher.md index 5bdc7e9..0943107 100644 --- a/.claude/agents/spec-researcher.md +++ b/.claude/agents/spec-researcher.md @@ -229,4 +229,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Write research document 5. Self-check: are all completion criteria met? Is the edge case list comprehensive? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/spec-validator.md b/.claude/agents/spec-validator.md index 5274933..58266df 100644 --- a/.claude/agents/spec-validator.md +++ b/.claude/agents/spec-validator.md @@ -364,4 +364,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Write the validation report 5. Self-check: did you check every item? Did you verify against the actual codebase, not assumptions? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/agents/spec-writer.md b/.claude/agents/spec-writer.md index 8a7f9d5..681284b 100644 --- a/.claude/agents/spec-writer.md +++ b/.claude/agents/spec-writer.md @@ -440,4 +440,10 @@ This agent runs in a Ralph loop until all completion criteria are met. Each iter 4. Verify every edge case has a defined behaviour 5. Self-check: are all completion criteria met? Is there any ambiguity an implementation agent would stumble on? -If not, iterate. If yes, signal completion to the orchestrator. \ No newline at end of file +If not, iterate. If yes, signal completion to the orchestrator. + + + + + + diff --git a/.claude/backlog.md b/.claude/backlog.md new file mode 100644 index 0000000..46365e7 --- /dev/null +++ b/.claude/backlog.md @@ -0,0 +1,124 @@ +# Mars Mission Fund β€” Feature Backlog + +> Auto-generated by Product Strategist. Last updated: 2026-03-05 + +## Status Key + +- πŸ”² TODO β€” Not started +- πŸ“ SPECCING β€” Spec track working on it +- βœ… SPECCED β€” Spec validated, ready for implementation +- πŸ”¨ BUILDING β€” Implementation in progress +- πŸ§ͺ TESTING β€” In quality gate +- βœ… SHIPPED β€” Merged to main + +## Backlog + +| # | Feature | Context | Priority | Status | Dependencies | Complexity | +|---|---------|---------|----------|--------|-------------|------------| +| 001 | Account Registration and Authentication | Account (L4-001) | P0 | βœ… SHIPPED | None | M | +| 002 | KYC Verification Stub | KYC (L4-005) | P0 | βœ… SHIPPED | feat-001 | S | +| 003 | Campaign Creation, Submission, and Review Pipeline | Campaign (L4-002) | P0 | πŸ”² | feat-001, feat-002 | L | +| 004 | Campaign Discovery and Public Campaign Pages | Donor (L4-003), Campaign (L4-002) | P1 | πŸ”² | feat-003 | M | +| 005 | Contribution Flow and Payment Processing (Stubbed Gateway) | Payments (L4-004), Donor (L4-003) | P1 | πŸ”² | feat-001, feat-003, feat-004 | L | +| 006 | Donor Dashboard and Contribution History | Donor (L4-003) | P1 | πŸ”² | feat-001, feat-005 | M | + +--- + +## Dependency Graph + +```text +feat-001 Account Auth + └── feat-002 KYC Stub + └── feat-003 Campaign Lifecycle + └── feat-004 Campaign Discovery + └── feat-005 Contribution Flow + └── feat-006 Donor Dashboard +``` + +feat-001 and feat-002 are also direct dependencies of feat-005 and feat-006 respectively. + +--- + +## Prioritisation Rationale + +### P0 β€” Foundation Features (must exist before anything else) + +**feat-001 Account Auth** is the identity foundation. +Every authenticated endpoint, every role check, and every user-scoped data query depends on Clerk JWT validation and the users table. +Nothing else can be built without it. + +**feat-002 KYC Stub** is the gating mechanism for campaign submission and disbursement. +Even as a stub (auto-approve), the status lifecycle and enforcement must be in place before feat-003 can enforce the Creator role prerequisite. +It is small (S) and can be implemented in the same cycle as feat-001. + +**feat-003 Campaign Lifecycle** is the core platform feature. +The review pipeline, campaign state machine, and milestone definitions are the primary demo value. +Without live campaigns, donors have nothing to discover or contribute to. +All subsequent P1 features depend on campaigns existing. + +### P1 β€” Core Value Features (deliver primary user value) + +**feat-004 Campaign Discovery** is the donor entry point. +Backers must be able to find and read campaigns before they can contribute. +It spans two bounded contexts (Donor and Campaign read model) but only reads data β€” no mutations in the Campaign domain. + +**feat-005 Contribution Flow** is the financial core. +The payment adapter pattern, escrow ledger, and contribution state machine are the primary architectural demonstration of the platform's financial model. +The stub gateway means no real money moves, but the pattern is fully real. + +**feat-006 Donor Dashboard** closes the donor loop. +Post-contribution visibility (history, milestone progress, totals) is essential for demonstrating the "transparency as currency" principle and completing the end-to-end donor journey. + +--- + +## Phase 2 Features (explicitly out of scope for this backlog) + +The following features appear in the product vision but are Phase 2 and must not be specced or implemented in this cycle: + +- Real Stripe gateway integration (live credentials, webhook handling). +- Real Veriff KYC integration (document upload, automated verification, sanctions screening). +- Real AWS SES email notifications. +- Recommendation engine (collaborative filtering, personalised discovery). +- Tax receipt PDF generation. +- Multi-approval disbursement workflow (requires two admins). +- Daily reconciliation and financial reporting dashboards. +- Social sharing post-contribution. +- Campaign deadline enforcement automation (cron-based Live β†’ Funded / Failed transitions). +- Milestone-based fund release (disbursement execution, not just trigger wiring). +- Account deactivation, GDPR erasure, and data portability. +- MFA enrollment and session elevation. + +--- + +## Notes for Implementation Agents + +- All monetary amounts are stored as BIGINT (integer cents) in the database and serialised as strings in JSON. +- The authenticated `user_id` is sourced from the Clerk JWT auth context β€” never from request body or query parameters. +- Every feature must include: domain unit tests (>=90% coverage), application service integration tests with mock adapters, and API endpoint integration tests for happy path and primary error paths. +- The single migration already in `db/migrations/` defines the `update_updated_at_column()` trigger function β€” all new tables should attach this trigger to their `updated_at` column. +- No ORM, no query builder β€” raw SQL via `pg` with parameterised queries only. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/context/domain-knowledge.md b/.claude/context/domain-knowledge.md new file mode 100644 index 0000000..ceeb5a2 --- /dev/null +++ b/.claude/context/domain-knowledge.md @@ -0,0 +1,299 @@ +# Domain Knowledge + +> Accumulated domain knowledge for the Mars Mission Fund platform. +> Updated by Spec Researcher agents across feature cycles. + +--- + +## Clerk Integration + +### Clerk User ID Format + +Clerk user IDs are prefixed KSUIDs β€” strings of the form `user_2abc3XYZ...`. +They are **not UUIDs**. +All MMF database columns that reference Clerk user identity must use `TEXT` (not `UUID`). +This affects every table with a `clerk_user_id` FK. + +### Dual-Record Pattern + +Every authenticated user has two records: + +1. **Clerk user record** β€” owns credentials, email verification, MFA, SSO links, session tokens. +2. **MMF `users` table row** β€” owns application data: roles, onboarding state, display name, bio, avatar URL, notification preferences. + +These records are linked by `clerk_user_id` (MMF stores Clerk's `sub` claim value). +The MMF record is created lazily on first API call or via Clerk webhook. + +### Role Storage Pattern (RBAC) + +- **Source of truth**: MMF `users.roles` column (TEXT array) in the database. +- **Cache for JWT**: Clerk `publicMetadata.role` (primary role as string) β€” synced whenever role changes. +- **JWT embedding**: A Clerk JWT template adds `role` from `publicMetadata` to session token claims. +- This avoids a Clerk API call on every request while keeping the DB authoritative. +- `publicMetadata` can only be written from the backend, not the frontend (unlike `unsafeMetadata`). + +### Clerk Metadata Types + +| Type | Read | Write | MMF Use | +|------|------|-------|---------| +| `publicMetadata` | Frontend + Backend | Backend only | Role cache for JWT embedding | +| `privateMetadata` | Backend only | Backend only | Internal IDs, admin flags | +| `unsafeMetadata` | Frontend + Backend | Frontend + Backend | Do NOT use for roles (can be tampered) | + +### Session Token JWT Claims (Default) + +Default Clerk session tokens contain: `sub` (user ID), `sid` (session ID), `azp`, `iss`, `exp`, `iat`. +They do NOT include roles or `publicMetadata` by default. +A Clerk Dashboard JWT template must be configured to embed role data. + +### Express Middleware Pattern + +```typescript +import { clerkMiddleware, requireAuth, getAuth } from '@clerk/express' + +app.use(clerkMiddleware()) // attaches req.auth to all requests +app.use('/v1', requireAuth()) // protects all /v1 routes + +// In a handler: +const { userId } = getAuth(req) // Clerk user ID (TEXT, e.g. "user_abc123") +``` + +`requireAuth()` by default redirects unauthenticated users (web behaviour). +For an API server, configure a custom `unauthorizedHandler` that returns 401 JSON instead. + +### Webhook Events + +Clerk uses Svix to sign webhooks. +Webhook signature verification uses `CLERK_WEBHOOK_SECRET` and `verifyWebhook()` from `@clerk/backend`. +Key events for feat-001: +- `user.created` β€” trigger lazy MMF user record creation +- `user.updated` β€” sync email, account status, linked SSO identities + +All webhook handlers must be idempotent (upsert semantics). +Webhooks may arrive out of order or be replayed. + +### Session Token Version + +As of April 2025, Clerk deprecated session token v1 and released v2. +Ensure the Clerk Dashboard is configured to use v2 and the SDK (`@clerk/express`) supports it. + +### Enumeration Protection + +Clerk has opt-in enumeration attack protection (released August 2025). +Must be explicitly enabled in the Clerk Dashboard to prevent email existence disclosure via sign-up/sign-in responses. + +--- + +## Account Domain + +### Account States + +Five states per L4-001: +- `pending_verification` β€” email not yet confirmed; limited access +- `active` β€” email confirmed; full access per roles +- `suspended` β€” administratively suspended; no access +- `deactivated` β€” user-initiated; may reactivate within 90 days +- `deleted` β€” GDPR erasure complete; row removed from DB + +The `deleted` state is not stored in the DB β€” the row is removed. +Audit records retain anonymised references. + +### Role Definitions + +| Role | Assignment | KYC Required | Default | +|------|-----------|--------------|---------| +| Backer | Automatic on activation | No | Yes | +| Creator | Self-select + KYC | Yes (Verified) | No | +| Reviewer | Assigned by Administrator | No | No | +| Administrator | Assigned by Super Administrator | No | No | +| Super Administrator | Assigned by another Super Admin with MFA | No | No | + +`Backer` is the default role granted automatically when an account reaches `active` state. +A user may hold multiple roles simultaneously. +Role changes must be logged as security-critical audit events. + +### KYC Gate + +The Creator role requires `kyc_status = 'verified'` before Creator-gated features are accessible. +The role may be assigned before KYC is complete, but features remain locked until KYC passes. +This means the API layer must check both role AND KYC status for Creator-gated endpoints. + +### Anti-Enumeration + +Registration and password-reset endpoints must return identical responses for registered and unregistered emails. +Never reveal whether an existing account uses SSO or password auth (per AC-ACCT-002). + +### Notification Preferences + +Six categories (from L4-001 Section 4.2): +1. Campaign updates (backed campaigns) +2. Milestone completions +3. Contribution confirmations +4. New campaign recommendations +5. Account security alerts β€” **mandatory, cannot be disabled** +6. Platform announcements + +Stored as JSONB in `users.notification_prefs`. +Default: all opt-in except platform announcements (opt-out). +Security alerts: always forced `true`, cannot be set to `false` at the API layer. + +--- + +## KYC Domain + +### KYC Status State Machine (Stub Scope) + +The full L4-005 lifecycle has 9 states. The stub (feat-002) implements only: + +``` +not_started β†’ pending (user calls POST /kyc/submit) +pending β†’ verified (stub auto-approves synchronously) +failed β†’ pending (user resubmits after failure β€” allowed) +``` + +In production (real Veriff), transitions via `in_review` and `pending_resubmission` apply. +Those states exist in the domain design but are out of scope for the local demo. + +### KYC vs. Role Gate (Critical Distinction) + +Do NOT conflate the Creator role with KYC verification status. They are independent: +- The Creator role may be assigned BEFORE KYC is complete. +- Creator-gated features require BOTH `roles CONTAINS 'creator'` AND `kyc_status = 'verified'`. +- API endpoints that gate on KYC must check both β€” role alone is insufficient. +- Error code when KYC is required but not verified: `KYC_NOT_VERIFIED`, HTTP 403. + +### KYC DB Column Naming Mismatch + +The current `users.kyc_status` CHECK constraint uses `'failed'` as the rejection value. +L4-005 calls this state "Rejected". This naming inconsistency should be resolved in the +feat-002 migration by renaming `'failed'` to `'rejected'` in the CHECK constraint and the +`KycStatus` value object. + +### KYC Adapter Interface (Port Design) + +The `KycVerificationPort` interface in `packages/backend/src/kyc/ports/kyc-provider.port.ts` +must define the contract for both the stub and the eventual real Veriff adapter: + +```typescript +interface KycSessionResult { + sessionId: string; + sessionUrl?: string; // not used by stub + outcome: 'approved' | 'declined' | 'pending'; +} + +interface KycVerificationPort { + initiateSession(userId: string): Promise; +} +``` + +The stub always returns `{ sessionId: 'stub-session', outcome: 'approved' }` synchronously. +The real Veriff adapter would return a session URL and wait for a webhook. + +### Audit Events for KYC + +KYC status transitions emit two distinct audit events per stub submission: +1. `kyc.status.change` β€” `not_started β†’ pending` +2. `kyc.status.change` β€” `pending β†’ verified` + +Each event must include `previous_status`, `new_status`, `trigger_reason`, and actor identity. +Document content and PII are NEVER included in audit events (per L3-006). + +The `kyc_audit_events` table stores KYC-specific events. It is NOT the general `audit_events` +table β€” KYC audit is its own table for retention and data classification reasons. + +### Veriff (Production KYC Provider) + +Veriff uses a session-based flow: +- Create session β†’ receive session URL and ID. +- User completes verification in Veriff's hosted flow. +- Veriff sends `decision` webhook (HMAC-SHA256 signed) with outcome. + +For MMF, the Veriff adapter is behind the `KycVerificationPort` interface. +The `MOCK_KYC=true` environment variable selects the stub adapter at composition root level. + +--- + +## Database + +### Migration Convention + +- Location: `db/migrations/` +- Naming: `YYYYMMDDHHMMSS_description.sql` +- Format: dbmate format with `-- migrate:up` and `-- migrate:down` sections +- Wrap in `BEGIN; ... COMMIT;` +- Append-only: never modify existing migrations + +### Existing Migrations + +| Timestamp | Description | +|-----------|-------------| +| 20260305120000 | `add_updated_at_trigger` β€” creates `update_updated_at_column()` trigger function | + +### Schema Conventions + +- Monetary: `BIGINT` (integer cents), never FLOAT +- Timestamps: `TIMESTAMPTZ`, never bare `TIMESTAMP` +- All tables: `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` +- `updated_at` auto-update trigger (using the `update_updated_at_column()` function already created) +- Index on every FK column +- Index on columns used in WHERE/ORDER BY +- Explicit `ON DELETE` on every FK +- CHECK constraints for domain invariants + +--- + +## Testing + +### Clerk Mock Strategy + +For Vitest integration tests, mock `@clerk/express` to bypass real JWT validation: + +```typescript +vi.mock('@clerk/express', () => ({ + clerkMiddleware: () => (req, _res, next) => next(), + requireAuth: () => (req, res, next) => { + if (!req.auth?.userId) { + return res.status(401).json({ error: { code: 'UNAUTHENTICATED', message: 'Authentication required' } }) + } + next() + }, + getAuth: (req) => req.auth ?? { userId: null } +})) +``` + +Inject auth state per test by setting `req.auth` via a test-only middleware that reads `x-test-user-id` header. +Never use real Clerk tokens in unit or integration tests. + +### Test Data Conventions + +- Use realistic Clerk user IDs in test data: `user_test_` prefix + deterministic suffix (e.g., `user_test_backer01`, `user_test_admin01`) +- Avoid round numbers for monetary amounts (per backend rules) +- Use realistic names and emails in seed data + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/context/gotchas.md b/.claude/context/gotchas.md new file mode 100644 index 0000000..5d559a7 --- /dev/null +++ b/.claude/context/gotchas.md @@ -0,0 +1,387 @@ +# Gotchas + +> Known pitfalls, anti-patterns, and implementation traps discovered during research cycles. +> Updated by Spec Researcher agents across feature cycles. + +--- + +## Clerk Integration Gotchas + +### G-001: Clerk User ID Is NOT a UUID + +**Problem:** Clerk user IDs are prefixed KSUIDs (`user_2abc3XYZ...`), not UUIDs. +Using `UUID` type for `clerk_user_id` columns in PostgreSQL will cause type mismatch errors at runtime. + +**Fix:** Always use `TEXT` (or `VARCHAR`) for `clerk_user_id` columns. +Never use `UUID` for this field. +Every table that references user identity must use `TEXT`. + +**Detected in:** feat-001 research + +--- + +### G-002: JWT Does NOT Include Roles by Default + +**Problem:** Clerk's default session tokens contain only: `sub`, `sid`, `azp`, `iss`, `exp`, `iat`. +They do NOT include roles or `publicMetadata`. +Backend code that tries to read role claims from the JWT without configuring a JWT template will find nothing. + +**Fix:** Configure a Clerk Dashboard JWT template to embed the role claim: +- Go to Sessions β†’ Customize session token in the Clerk Dashboard. +- Add: `"role": "{{user.publicMetadata.role}}"`. +Without this, every request requires a separate Clerk API call to fetch user metadata β€” a performance and availability anti-pattern. + +**Detected in:** feat-001 research + +--- + +### G-003: `requireAuth()` Redirects Instead of Returning 401 + +**Problem:** Clerk's `requireAuth()` middleware in `@clerk/express` by default redirects unauthenticated users to a sign-in page. +For a REST API server, this returns a 3xx redirect response where a 401 JSON error is expected. +This breaks API clients that are not browsers. + +**Fix:** Configure `requireAuth()` with a custom `unauthorizedHandler`: + +```typescript +requireAuth({ + signInUrl: undefined, + unauthorizedHandler: (req, res) => { + res.status(401).json({ + error: { + code: 'UNAUTHENTICATED', + message: 'Authentication required', + correlation_id: req.correlationId + } + }) + } +}) +``` + +Or, use `clerkMiddleware()` + manual `getAuth()` check in each handler, returning 401 manually. + +**Detected in:** feat-001 research + +--- + +### G-004: Webhook Handlers Must Be Idempotent + +**Problem:** Clerk delivers webhooks at-least-once and does not guarantee delivery order. +`user.created` may arrive after `user.updated`. +A webhook handler that blindly inserts a new row on `user.created` will fail if the row already exists. + +**Fix:** All webhook handlers and the `/v1/auth/sync` endpoint must use upsert semantics: + +```sql +INSERT INTO users (clerk_user_id, email, ...) +VALUES ($1, $2, ...) +ON CONFLICT (clerk_user_id) DO UPDATE SET email = EXCLUDED.email, updated_at = NOW(); +``` + +Handle `user.updated` by checking if the MMF user record exists; create it if not (do not assume `user.created` arrived first). + +**Detected in:** feat-001 research + +--- + +### G-005: Cookie Size Limit with JWT Templates + +**Problem:** Clerk stores session tokens in cookies. +Most browsers limit cookies to 4KB. +If the Clerk JWT template embeds too much data (e.g., the full role array plus other metadata), the cookie exceeds the limit and Clerk cannot set it, breaking authentication for ALL requests. + +**Fix:** Keep the JWT template minimal. +Embed only the primary role as a string (`"role": "{{user.publicMetadata.role}}"`), not the full role array or any large metadata objects. +For features that need the full role set, query the MMF database using `clerk_user_id`. + +**Detected in:** feat-001 research + +--- + +### G-006: `unsafeMetadata` Can Be Modified from the Frontend + +**Problem:** Using `unsafeMetadata` to store MMF roles would allow any frontend client to self-assign roles by calling `user.update({ unsafeMetadata: { role: 'super_administrator' } })`. +This is a critical privilege escalation vulnerability. + +**Fix:** Always store MMF roles in `publicMetadata` (backend-write only) or in the MMF database. +Never use `unsafeMetadata` for authorization-relevant data. + +**Detected in:** feat-001 research + +--- + +### G-007: Enumeration Protection Is Opt-In + +**Problem:** Without explicitly enabling Clerk's enumeration protection in the Dashboard, Clerk's hosted sign-up/sign-in UI may reveal whether an email address is already registered (different error messages for known vs. unknown emails). +This violates AC-ACCT-002 and L3-002 security requirements. + +**Fix:** Enable the "Enumeration protection" setting in the Clerk Dashboard (released August 2025). +Additionally, ensure MMF's own API endpoints (`GET /me`, error responses from `/v1/auth/sync`) never reveal whether an email exists in the system. + +**Detected in:** feat-001 research + +--- + +### G-008: Session Token Version Mismatch + +**Problem:** As of April 2025, Clerk released session token v2 and deprecated v1. +If the Clerk Dashboard is still using v1 but the `@clerk/express` SDK expects v2 format, token parsing fails silently or throws cryptic errors. + +**Fix:** When setting up the Clerk application: +1. Check the Clerk Dashboard β†’ Updates β†’ Session token version. +2. Upgrade to v2 if on v1. +3. Ensure the `@clerk/express` package version supports the SDK API version `2025-04-10` or later. + +**Detected in:** feat-001 research + +--- + +### G-009: Email Column Staleness After Clerk Profile Update + +**Problem:** Clerk allows users to change their email address via Clerk's hosted account portal. +If MMF stores a copy of the email in `users.email`, this copy becomes stale without a webhook sync. + +**Fix:** Register a `user.updated` webhook handler that updates `users.email` whenever the email changes. +Alternatively, avoid storing email in MMF's DB and always derive it from the JWT or a Clerk API call (trade-off: no email without a JWT or API call, complicates server-side email sending). +Recommended: store email in MMF DB but treat it as a cache, synced via `user.updated` webhook. + +**Detected in:** feat-001 research + +--- + +## Database / Schema Gotchas + +### G-010: Do Not Store `deleted` Status in the Users Table + +**Problem:** Storing `account_status = 'deleted'` as a row in the `users` table after GDPR erasure means the row still contains PII in other columns (email, display name, etc.), defeating the purpose of erasure. + +**Fix:** When a user is deleted (GDPR erasure, or 90-day reactivation window expires): +1. Delete the row from `users` entirely. +2. Audit log entries use anonymised references (no PII). +3. Other tables referencing `users.id` must define `ON DELETE SET NULL` or `ON DELETE CASCADE` as appropriate. +Plan the cascade rules in the migration for each FK relationship before implementing deletion. + +**Detected in:** feat-001 research + +--- + +### G-011: `notification_prefs` Security Alerts Must Be Hardcoded True + +**Problem:** If the API blindly writes the `notification_prefs` JSONB from the request body, a user could send `{ "security_alerts": false }` and disable mandatory security notifications, violating L4-001 Section 4.2 and AC-ACCT-018. + +**Fix:** The PATCH `/me/notifications` endpoint must always enforce `security_alerts: true` regardless of what the request body contains. +Either: +- Validate with Zod that `security_alerts` is not present in the request body (disallow setting it), or +- After applying the request body update, always overwrite `security_alerts` to `true` before persisting. + +**Detected in:** feat-001 research + +--- + +## KYC Domain Gotchas + +### G-018: KYC Status Column Value Naming Mismatch + +**Problem:** The `users.kyc_status` CHECK constraint (created in feat-001) uses `'failed'` as the +value for rejected verification. L4-005 calls this state "Rejected". The `KycStatus` value object +also uses `Failed: 'failed'`. This inconsistency makes the code misleading and will cause confusion +when implementing the full state machine. + +**Fix:** The feat-002 migration must rename `'failed'` to `'rejected'` in the CHECK constraint. +Add an `ALTER TABLE` in the migration to drop and recreate the constraint with the new value. +Update the `KycStatus` value object to use `Rejected: 'rejected'`. Update all references in +`pg-user-repository.adapter.ts` and frontend `UserProfile` type. + +**Detected in:** feat-002 research + +--- + +### G-019: Audit Event Ordering β€” DB Update Before Audit Log + +**Problem:** If the audit event is emitted BEFORE the DB update, and the DB update subsequently +fails (timeout, constraint violation), the audit log shows a state transition that never completed. +The audit trail becomes inaccurate. + +**Fix:** Always perform the DB state update first, then emit the audit event. If the audit event +emission fails (e.g., insert to `kyc_audit_events` fails), log the error to pino but do not +roll back the state update β€” the audit is best-effort for the local demo. For production, the +audit insert should be in the same DB transaction as the status update. + +**Detected in:** feat-002 research + +--- + +### G-020: Concurrent KYC Submit Requests Need Conditional WHERE + +**Problem:** Two simultaneous `POST /kyc/submit` requests (e.g., double-click, React strict mode +double-invocation) could both read `kyc_status = 'not_started'`, both pass the validation check, +and both trigger the state machine β€” resulting in two audit events and potentially two competing +DB updates. + +**Fix:** The `updateKycStatus()` repository method for the `not_started β†’ pending` transition +must use a conditional WHERE clause: + +```sql +UPDATE users +SET kyc_status = 'pending', updated_at = NOW() +WHERE clerk_user_id = $1 AND kyc_status = 'not_started' +RETURNING * +``` + +If the UPDATE affects 0 rows (because another concurrent request already changed the status), +the application service must treat this as a conflict and return `409 KYC_ALREADY_PENDING`. +This makes the transition atomic without requiring an explicit lock. + +**Detected in:** feat-002 research + +--- + +### G-021: `AuditLoggerPort.resourceType` Typed as `'user'` Only + +**Problem:** The `AuditEntry` interface in `packages/backend/src/account/ports/audit-logger.port.ts` +has `resourceType: 'user'` as a literal type. KYC audit events should use `resourceType: 'kyc'` +per L3-006 Section 4.1. TypeScript will reject `resourceType: 'kyc'` until the union is expanded. + +**Fix:** Change the `resourceType` field type to `'user' | 'kyc'` (and future resource types as +needed). Update the `AuditEntry` interface: + +```typescript +readonly resourceType: 'user' | 'kyc'; +``` + +**Detected in:** feat-002 research + +--- + +### G-022: `in_review` DB Value Reserved but Not Used by Stub + +**Problem:** The `users.kyc_status` CHECK constraint includes `'in_review'` as a valid value. +The stub never transitions to `in_review` (it auto-approves). Code that assumes all valid +CHECK constraint values are reachable via the stub will be confused. + +**Fix:** The `in_review` value is reserved for the real Veriff integration. The stub state +machine only uses `not_started`, `pending`, and `verified` (and `rejected` for failure testing). +Document this clearly in the KYC application service and test coverage β€” do not write tests for +`in_review` in feat-002; they belong in the real Veriff adapter feature. + +**Detected in:** feat-002 research + +--- + +## Testing Gotchas + +### G-012: Never Use Real Clerk Tokens in Unit or Integration Tests + +**Problem:** Real Clerk tokens expire quickly (5-minute access tokens), are tied to a specific Clerk application instance, and require network calls to JWKS endpoints. +Using real tokens in tests makes tests flaky and environment-dependent. + +**Fix:** Always mock `@clerk/express` in tests: + +```typescript +vi.mock('@clerk/express', () => ({ + clerkMiddleware: () => (_req, _res, next) => next(), + requireAuth: () => (req, res, next) => { + if (!req.auth?.userId) return res.status(401).json({ error: { code: 'UNAUTHENTICATED' } }) + next() + }, + getAuth: (req) => req.auth ?? { userId: null } +})) +``` + +Inject `req.auth` via test-specific middleware that reads a `x-test-user-id` header. + +**Detected in:** feat-001 research + +--- + +### G-013: Concurrent Auth/Sync Requests Create Duplicate Users Without UNIQUE Constraint + +**Problem:** Frontend may fire two concurrent requests to `/v1/auth/sync` (double login event, React strict mode double invocation, etc.). +Without a UNIQUE constraint on `clerk_user_id`, two rows may be created for the same user. + +**Fix:** +1. The `users` table migration must include `UNIQUE` constraint on `clerk_user_id`. +2. The `/v1/auth/sync` endpoint must use `INSERT ... ON CONFLICT (clerk_user_id) DO UPDATE` (upsert). +3. Tests must cover the concurrent creation scenario. + +**Detected in:** feat-001 research + +--- + +## Architecture Gotchas + +### G-014: No Packages Directory β€” Monorepo Structure Needs Scaffolding + +**Problem:** The repository has no `packages/` directory. +There are no `packages/backend` or `packages/frontend` subdirectories. +Any implementation agent that assumes a pre-existing monorepo structure will fail to find the codebase. + +**Fix:** The first implementation feature (feat-001) must scaffold: +- `packages/backend/` β€” Express app with hexagonal architecture +- `packages/frontend/` β€” React + Vite app + +Update `package.json` workspaces configuration to include both packages. + +**Detected in:** feat-001 research + +--- + +### G-015: Health Endpoint Must Not Require Clerk Auth + +**Problem:** Per the feature brief, `/health` is the only unauthenticated endpoint. +If `clerkMiddleware()` or `requireAuth()` is applied globally at the top level before the `/health` route, health checks will fail when Clerk's JWKS endpoint is unreachable (circular dependency during startup). + +**Fix:** +1. Register `/health` route BEFORE applying `clerkMiddleware()` or `requireAuth()`. +2. Or, exclude `/health` from the `requireAuth()` middleware scope by applying it only to `/v1/` routes. + +**Detected in:** feat-001 research + +--- + +## Pre-commit Hook Gotchas + +### G-016: Hooks With `-r` Flag Scan Everything When Called With No Files + +**Problem:** When pre-commit calls a hook and no staged files match the hook's `types` filter, pre-commit passes an empty filenames list. A hook using `grep -rn "pattern" -- "$@"` with `$@` empty will have `grep` scan the current directory recursively (GNU grep default when no files given with `-r`), matching `node_modules/`, `autonomous/`, and other directories. + +This causes false positives for `check-merge-conflict` (lines ending with `=====...====` in HISTORY.md files match `=======$`), `detect-private-key` (test fixtures in `node_modules/@clerk/backend`), and `trailing-whitespace` (`sed: no input files`). + +**Fix:** Add an early exit when no filenames are passed: +```sh +sh -c 'if [ $# -eq 0 ]; then exit 0; fi; if grep -n "pattern" -- "$@"; then exit 1; fi; exit 0' +``` + +Remove the `-r` flag from grep (not needed when pre-commit passes explicit filenames). + +**Note:** `.pre-commit-config.yaml` is in `.gitignore` (runtime-injected). Edits apply for the current session but are not committed. If this keeps happening across sessions, the `autonomous/scripts/.pre-commit-config.yaml` source file needs to be updated. + +**Detected in:** feat-001 pipeline + +--- + +### G-017: `=======$` Grep Pattern Matches `=====...=====` Section Dividers + +**Problem:** The regex `=======$` (without `^`) matches any line that *ends* with 7+ `=` characters, including `# =============================================` comment dividers common in shell scripts. + +**Fix:** Always anchor with `^`: use `^=======$` to match only a line that is *exactly* `=======` (a genuine merge conflict marker). + +**Detected in:** feat-001 pipeline + + + + + + + + + + + + + + + + + + diff --git a/.claude/context/patterns.md b/.claude/context/patterns.md new file mode 100644 index 0000000..3a536ca --- /dev/null +++ b/.claude/context/patterns.md @@ -0,0 +1,332 @@ +# Patterns + +> Established implementation patterns for Mars Mission Fund. +> Updated by implementation agents across feature cycles. + +--- + +## Domain Layer Patterns + +### P-001: Value type constants (no TypeScript enums) + +Per WARN-001, TypeScript enums are prohibited. Use `as const` object + union type: + +```typescript +export const AccountStatus = { + PendingVerification: 'pending_verification', + Active: 'active', + Suspended: 'suspended', + Deactivated: 'deactivated', +} as const; + +export type AccountStatus = (typeof AccountStatus)[keyof typeof AccountStatus]; +``` + +**Files:** `packages/backend/src/account/domain/value-objects/account-status.ts`, role.ts, kyc-status.ts, onboarding-step.ts + +--- + +### P-002: Entity immutability with private constructor + +All domain entities use private constructor, static `create()` (with validation), and static `reconstitute()` (no validation): + +```typescript +export class User { + private constructor(private readonly props: UserData) {} + + static create(input: CreateUserInput): User { /* validates */ } + static reconstitute(data: UserData): User { /* no validation */ } +} +``` + +All state mutations return a **new** instance β€” entities are immutable after creation. + +**File:** `packages/backend/src/account/domain/models/user.ts` + +--- + +### P-003: Domain errors extend DomainError with unique code + +```typescript +export abstract class DomainError extends Error { + abstract readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = code; + Object.setPrototypeOf(this, new.target.prototype); // Fix prototype chain for instanceof + } +} + +export class UserNotFoundError extends DomainError { + readonly code = 'USER_NOT_FOUND'; + constructor() { super('USER_NOT_FOUND', "We couldn't find your account."); } +} +``` + +**Files:** `packages/backend/src/shared/domain/errors.ts`, account/domain/errors/account-errors.ts + +--- + +### P-004: SecurityAlerts literal true type pattern + +`securityAlerts` has TypeScript type `true` (literal, not `boolean`). Runtime guard uses `as unknown` cast to compare against `false`: + +```typescript +if ('securityAlerts' in prefs && (prefs.securityAlerts as unknown) === false) { + throw new SecurityAlertsCannotBeDisabledError(); +} +``` + +**Files:** `user.ts`, `account-app-service.ts` + +--- + +## Adapter Patterns + +### P-005: PG repository row mapping + +Database rows use `snake_case`. Domain entities use `camelCase`. Convert explicitly in `rowToDomain()`: + +```typescript +function rowToDomain(row: UserRow): User { + return User.reconstitute({ + id: row.id, + clerkUserId: row.clerk_user_id, + notificationPrefs: { + campaignUpdates: row.notification_prefs.campaign_updates, + securityAlerts: true, // Always forced β€” never read from DB + }, + }); +} +``` + +**File:** `packages/backend/src/account/adapters/pg-user-repository.adapter.ts` + +--- + +### P-006: Upsert semantics for idempotency + +All create/sync operations use `ON CONFLICT ... DO UPDATE` to handle concurrent requests and at-least-once webhook delivery: + +```sql +INSERT INTO users (clerk_user_id, email, ...) +ON CONFLICT (clerk_user_id) +DO UPDATE SET email = EXCLUDED.email, last_seen_at = NOW(), updated_at = NOW() +``` + +**Per:** Gotcha G-004, G-013 + +--- + +## API Patterns + +### P-007: requireAuth returns JSON 401 (not redirect) + +Per gotcha G-003, Clerk's `requireAuth()` in `@clerk/express` v1.x does NOT support `unauthorizedHandler`. Implement manually using `getAuth()`: + +```typescript +export function createRequireAuth(): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const auth = getAuth(req); + if (!auth.userId) { + res.status(401).json({ + error: { code: 'UNAUTHENTICATED', message: '...', correlation_id: req.correlationId ?? null } + }); + return; + } + next(); + }; +} +``` + +**File:** `packages/backend/src/shared/middleware/auth.ts` + +--- + +### P-008: WARN-002 β€” All error responses include correlation_id + +Every error response must include `correlation_id`: + +```typescript +res.status(401).json({ + error: { + code: 'UNAUTHENTICATED', + message: 'Authentication required. Sign in to continue.', + correlation_id: req.correlationId ?? null, + }, +}); +``` + +`correlationId` is injected via `correlationIdMiddleware` which sets `req.correlationId = crypto.randomUUID()`. + +--- + +### P-009: Webhook route must use express.raw() before express.json() + +Per the Svix webhook verification requirement, the webhook route must receive the raw body as a `Buffer`: + +```typescript +// Register BEFORE express.json() +app.use( + '/api/v1/webhooks', + express.raw({ type: 'application/json' }), + createWebhookRouter(services.accountAppService, logger), +); + +// JSON body parsing for all other routes (registered AFTER webhook route) +app.use(express.json()); +``` + +**File:** `packages/backend/src/app.ts` + +--- + +### P-010: Health endpoint before Clerk middleware + +Per gotcha G-015, `/health` must be registered before `clerkMiddleware()`: + +```typescript +// Health check BEFORE clerkMiddleware +app.get('/health', (_req, res) => { ... }); + +// Webhook route (raw body) +app.use('/api/v1/webhooks', express.raw(...), webhookRouter); + +// JSON body parsing +app.use(express.json()); + +// Clerk middleware (registered after health + webhook routes) +app.use(clerkMiddleware()); +``` + +--- + +### P-011: WARN-003 β€” Profile PATCH schema includes onboarding fields + +The `PATCH /api/v1/me/profile` Zod schema includes `onboardingCompleted` and `onboardingStep`: + +```typescript +z.object({ + displayName: z.string().max(255).nullable().optional().transform(v => v === '' ? null : v), + bio: z.string().max(500).nullable().optional().transform(v => v === '' ? null : v), + avatarUrl: z.string().url().nullable().optional(), + onboardingCompleted: z.boolean().optional(), + onboardingStep: z.enum(['role_selection', 'profiling', 'notifications', 'complete']).optional(), +}).strict() +``` + +--- + +### P-012: WARN-004 β€” Profile route is /me/profile + +The profile update endpoint is `PATCH /api/v1/me/profile`, NOT `/api/v1/profile`. Registered in `createAccountRouter` under the `/api/v1` prefix as `/me/profile`. + +--- + +### P-013: WARN-005 β€” Assign Backer role on Active status + +In `syncUser`, always assign `[Role.Backer]` when `accountStatus === Active` and the roles array is empty: + +```typescript +const userToUpsert = + input.accountStatus === AccountStatus.Active && user.roles.length === 0 + ? User.reconstitute({ ...user, roles: [Role.Backer] }) + : user; +``` + +Same logic applies in webhook handlers (`user.created`, `user.updated` out-of-order delivery). + +--- + +## Testing Patterns + +### P-014: Mock Clerk in tests via vi.mock + +Per gotcha G-012, mock `@clerk/express` to avoid real JWT validation: + +```typescript +vi.mock('@clerk/express', () => ({ + clerkMiddleware: () => (_req, _res, next) => next(), + getAuth: (req) => { + const userId = req.headers['x-test-user-id']; + return { userId: userId ?? null }; + }, +})); +``` + +Inject `x-test-user-id` header in test requests. Real Clerk middleware is replaced with a header-based stub. + +**File:** `packages/backend/src/account/api/account-router.test.ts` + +--- + +### P-015: pino-http CJS interop in ESM context + +`pino-http` is a CJS module used in an ESM project. Import as namespace and handle default: + +```typescript +import * as pinoHttpModule from 'pino-http'; +const httpLogger = + (pinoHttpModule as unknown as { default: (opts: { logger: Logger }) => RequestHandler }) + .default ?? pinoHttpModule; +``` + +**File:** `packages/backend/src/app.ts` + +--- + +## Monorepo Patterns + +### P-016: Workspace package references + +`packages/backend` references `packages/shared` via `tsconfig.json` paths + composite references: + +```json +// packages/backend/tsconfig.json +{ + "compilerOptions": { + "paths": { "@mmf/shared": ["../shared/src/index.ts"] } + }, + "references": [{ "path": "../shared" }] +} +``` + +Vitest resolves `@mmf/shared` via `resolve.alias` in `vitest.config.ts`. + +--- + +## Migration Patterns + +### P-017: users table schema summary + +Key constraints on `users` table: +- `id UUID PRIMARY KEY DEFAULT gen_random_uuid()` +- `clerk_user_id TEXT NOT NULL UNIQUE` β€” never UUID (Gotcha G-001) +- `roles TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]` +- `notification_prefs JSONB NOT NULL DEFAULT { all true except platform_announcements }` +- `account_status TEXT CHECK (... 4 values ...)` +- All `TIMESTAMPTZ` β€” never bare `TIMESTAMP` +- `updated_at` auto-trigger via `update_updated_at_column()` function + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/hooks/lint-ts-file.sh b/.claude/hooks/lint-ts-file.sh new file mode 100755 index 0000000..d528111 --- /dev/null +++ b/.claude/hooks/lint-ts-file.sh @@ -0,0 +1,38 @@ +#!/bin/bash +FILE_PATH=$(jq -r '.tool_input.file_path // empty') +[ -z "$FILE_PATH" ] && exit 0 +[ -f "$FILE_PATH" ] || exit 0 +case "$FILE_PATH" in + *.ts|*.tsx|*.js|*.jsx|*.json) ;; + *) exit 0 ;; +esac +npx biome check --write "$FILE_PATH" 2>&1 | head -20 +exit 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/manual-tasks.md b/.claude/manual-tasks.md new file mode 100644 index 0000000..a71ffc1 --- /dev/null +++ b/.claude/manual-tasks.md @@ -0,0 +1,303 @@ +# Manual Tasks + +> Steps that cannot be automated β€” require human action in third-party dashboards. +> Maintained by the Infrastructure Engineer agent. +> Updated: 2026-03-05 (Task #3 added for feat-002) + +--- + +## Task #1 β€” Clerk Application Setup + +**Service:** Clerk (https://clerk.com) +**Blocked feature:** feat-001 (Account Registration and Authentication) +**Status:** TODO +**Priority:** High + +### What This Enables + +Users can register and sign in to Mars Mission Fund using email/password, Google SSO, or Microsoft SSO; Clerk issues session tokens that the backend validates on every request. + +### Steps + +1. Go to https://dashboard.clerk.com and sign in (or create a free account). + +2. Click **"Create application"**. + - Application name: `Mars Mission Fund` + - Under "How will your users sign in?", enable: + - Email address (with password) + - Google + - Microsoft + - Click **"Create application"**. + +3. **Copy your API keys** β€” shown immediately after creation on the "API Keys" page: + - `CLERK_PUBLISHABLE_KEY` β†’ starts with `pk_test_` + - `CLERK_SECRET_KEY` β†’ starts with `sk_test_` + - Add both to your `.env` file. + - Set `VITE_CLERK_PUBLISHABLE_KEY` to the same value as `CLERK_PUBLISHABLE_KEY`. + +4. **Enable enumeration protection** (security requirement β€” see gotcha G-007): + - In the left sidebar, go to **Configure > Restrictions**. + - Find **"Email enumeration protection"** and enable it. + - Click **"Save"**. + +5. **Confirm session token version is v2** (see gotcha G-008): + - In the left sidebar, go to **Configure > Sessions**. + - Under "Token version", ensure it shows **v2**. + - If it shows v1, click the upgrade button to migrate to v2. + +6. **Configure JWT template to include role claim** (see gotcha G-002): + - In the left sidebar, go to **Configure > Sessions**. + - Find **"Customize session token"** and click **"Edit"**. + - Add the following claim to the JSON template: + ```json + { + "role": "{{user.publicMetadata.role}}" + } + ``` + - Keep the template minimal β€” embedding large metadata causes cookie size issues (see gotcha G-005). + - Click **"Save"**. + +7. **Enable Google OAuth provider**: + - In the left sidebar, go to **User & Authentication > Social connections**. + - Find **Google** and toggle it on. + - For a development environment, the default shared credentials are fine. + - For production, create a Google OAuth app at https://console.cloud.google.com and enter your own Client ID and Secret. + +8. **Enable Microsoft OAuth provider**: + - Still on **User & Authentication > Social connections**. + - Find **Microsoft** and toggle it on. + - For production, create an app registration at https://portal.azure.com. + +9. **Register the Clerk webhook endpoint**: + - In the left sidebar, go to **Configure > Webhooks**. + - Click **"Add endpoint"**. + - URL: `https://your-domain.com/api/v1/webhooks/clerk` + (For local testing, use a tunnelling tool β€” see Verification section below.) + - Under **"Subscribe to events"**, enable: + - `user.created` + - `user.updated` + - Click **"Create"**. + - On the endpoint detail page, find **"Signing Secret"** β€” click the eye icon to reveal it. + - Copy the value (starts with `whsec_`) and set it as `CLERK_WEBHOOK_SECRET` in your `.env`. + +### Config Required + +```bash +CLERK_SECRET_KEY=sk_test_your_clerk_secret_key_here # β†’ add to .env +CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_key_here # β†’ add to .env +VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_key_here # β†’ add to .env +CLERK_WEBHOOK_SECRET=whsec_your_webhook_signing_secret_here # β†’ add to .env +MOCK_AUTH=false # β†’ Clerk is always a real integration +``` + +### Verification + +**Test API key works:** +```bash +curl https://api.clerk.com/v1/users \ + -H "Authorization: Bearer $CLERK_SECRET_KEY" +# Expect: {"data": [], "total_count": 0} (empty users list) +``` + +**Test webhook delivery locally** (using ngrok or similar): +```bash +npx ngrok http 3001 +# Copy the HTTPS tunnel URL and update your Clerk webhook endpoint URL +# Register with email/password in the app β€” check backend logs for webhook receipt +``` + +**Test JWT template is applied:** +After signing in via the frontend, inspect the session token using: +```bash +# Paste the JWT from browser devtools β†’ Application β†’ Cookies β†’ __session +# Decode at https://jwt.io and verify the "role" claim is present in the payload +``` + +### Currently Mocked By + +Clerk auth is a real integration β€” it is never mocked in production code. +In tests, `@clerk/express` is mocked at the module level (see gotcha G-012). + +--- + +## Task #2 β€” Local PostgreSQL Database Setup + +**Service:** PostgreSQL (via Docker Compose) +**Blocked feature:** feat-001 (Account Registration and Authentication) +**Status:** TODO +**Priority:** High + +### What This Enables + +The local development database is running and all migrations have been applied, so the backend can start and persist user records. + +### Steps + +1. Ensure Docker Desktop (or Docker Engine) is installed and running. + +2. From the project root, start the PostgreSQL service: + ```bash + docker compose up -d postgres + ``` + Wait for it to be healthy (check with `docker compose ps`). + +3. Run all database migrations: + ```bash + docker compose run --rm migrate + ``` + Expected output: + ``` + Applying: 20260305120000_add_updated_at_trigger.sql + Applying: 20260305130000_create_users_table.sql + ``` + +4. Verify the database is ready: + ```bash + docker compose exec postgres psql -U mmf -d mmf_dev -c "\dt" + # Expect: schema_migrations and users tables listed + ``` + +5. Copy `.env.example` to `.env` and fill in your Clerk keys (from Task #1): + ```bash + cp .env.example .env + # Edit .env with your real Clerk keys + ``` + +### Config Required + +```bash +DATABASE_URL=postgresql://mmf:mmf_password@localhost:5432/mmf_dev # β†’ add to .env +POSTGRES_USER=mmf # β†’ add to .env +POSTGRES_PASSWORD=mmf_password # β†’ add to .env +POSTGRES_DB=mmf_dev # β†’ add to .env +``` + +### Verification + +```bash +docker compose exec postgres psql -U mmf -d mmf_dev -c "SELECT COUNT(*) FROM users;" +# Expect: count = 0 (empty table, ready for use) +``` + +### Currently Mocked By + +PostgreSQL is the real database β€” it is never mocked. The `in-memory-user-repository.adapter.ts` is used in unit tests only (not a database mock). + +--- + +## Task #3 β€” Veriff KYC Integration + +**Service:** Veriff (https://veriff.com) +**Blocked feature:** feat-002 real KYC (the stub adapter is already working for local demo) +**Status:** ⬜ TODO +**Priority:** Low (stub works for demo) + +### What This Enables + +Real identity verification for Mars Mission Fund creators β€” Veriff performs document and selfie checks and returns a verified/rejected outcome via webhook, replacing the auto-approving stub adapter. + +### Steps + +1. Go to https://station.veriff.com and sign in (or create a Veriff account β€” contact sales@veriff.com for a sandbox account if you do not have one). + +2. **Create an integration:** + - In the left sidebar, go to **Integrations**. + - Click **"New integration"**. + - Name: `Mars Mission Fund` + - Environment: select **Sandbox** for testing; **Production** when going live. + - Click **"Create"**. + +3. **Copy your API credentials** β€” shown on the integration detail page: + - `API Key` β†’ copy and set as `VERIFF_API_KEY` in your `.env` file. + - The API key is used by the backend to create verification sessions. + +4. **Configure the webhook endpoint:** + - On the integration detail page, find **"Webhooks"** or **"Notifications"**. + - Click **"Add endpoint"**. + - URL: `https://your-domain.com/api/v1/webhooks/veriff` + (For local testing, use a tunnelling tool such as ngrok β€” see Verification section below.) + - Enable the following event types: + - `verification.decision` β€” fired when Veriff reaches a final approved/declined decision + - Click **"Save"**. + - On the webhook detail page, copy the **"HMAC Secret"** (or "Signing Secret") β€” set it as `VERIFF_WEBHOOK_SECRET` in your `.env`. + +5. **Set `MOCK_KYC=false`** in your `.env` to disable the stub and activate the real Veriff adapter. + +6. **Implement the real Veriff adapter** (backend engineer task): + - Create `packages/backend/src/kyc/adapters/veriff-kyc-provider.adapter.ts` implementing `KycVerificationPort`. + - Use the Veriff Node.js SDK (`@veriff/incontext-sdk` or the REST API directly). + - The `initiateSession()` method should create a Veriff session via `POST https://stationapi.veriff.com/v1/sessions` and return `{ sessionId, outcome: 'pending' }`. + - The final decision arrives via webhook β€” implement `POST /api/v1/webhooks/veriff` to receive the `verification.decision` event, validate the HMAC signature using `VERIFF_WEBHOOK_SECRET`, and call `kycAppService.handleVeriffDecision()`. + +7. **Wire the real adapter in the composition root:** + - In `packages/backend/src/composition-root.ts`, replace the stub instantiation when `MOCK_KYC=false`: + ```typescript + const kycProvider: KycVerificationPort = mockKyc + ? new StubKycVerificationAdapter(true) + : new VeriffKycProviderAdapter(process.env.VERIFF_API_KEY!); + ``` + +### Config Required + +```bash +VERIFF_API_KEY=your_veriff_api_key_here # β†’ add to .env +VERIFF_WEBHOOK_SECRET=your_veriff_hmac_secret # β†’ add to .env +MOCK_KYC=false # β†’ change from true to false in .env +``` + +### Verification + +**Test API key works (sandbox):** +```bash +curl -X POST https://stationapi.veriff.com/v1/sessions \ + -H "Content-Type: application/json" \ + -H "X-AUTH-CLIENT: $VERIFF_API_KEY" \ + -d '{"verification": {"callback": "https://your-domain.com", "person": {"firstName": "Test", "lastName": "User"}, "document": {"type": "PASSPORT", "country": "US"}, "lang": "en"}}' +# Expect: 201 response with a sessionId and a verification URL +``` + +**Test webhook delivery locally** (using ngrok or similar): +```bash +npx ngrok http 3001 +# Copy the HTTPS tunnel URL and update your Veriff webhook endpoint URL +# Complete a sandbox verification in the Veriff demo flow β€” check backend logs for webhook receipt +``` + +**Confirm stub is disabled:** +```bash +# With MOCK_KYC=false, calling POST /api/v1/kyc/submit should create a real Veriff session +# and return kycStatus: 'pending' (not 'verified') until the webhook arrives +``` + +### Currently Mocked By + +- `packages/backend/src/kyc/adapters/stub-kyc-provider.adapter.ts` +- Will be replaced by: `packages/backend/src/kyc/adapters/veriff-kyc-provider.adapter.ts` + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/mock-status.md b/.claude/mock-status.md new file mode 100644 index 0000000..a31e343 --- /dev/null +++ b/.claude/mock-status.md @@ -0,0 +1,73 @@ +# Mock vs Real Adapter Status + +> Tracks which external service integrations are mocked vs real. +> Maintained by the Infrastructure Engineer agent. +> Updated: 2026-03-05 (feat-002) + +--- + +## Current Status + +| Service | Status | Mock Adapter | Real Adapter | Manual Task | Feature | +|---------|--------|-------------|--------------|-------------|---------| +| Clerk Auth | Real | β€” (module-level mock in tests only) | `clerk-auth.adapter.ts` | Task #1 | feat-001 | +| KYC (Veriff) | Mocked | `stub-kyc-provider.adapter.ts` | `veriff-kyc-adapter.ts` (not yet built) | Task #3 | feat-002 | +| Payments (Stripe) | Mocked | `mock-payment-adapter.ts` | `stripe-payment-adapter.ts` (not yet built) | TBD | feat-005 | +| Email (AWS SES) | Mocked | `mock-email-adapter.ts` | `ses-email-adapter.ts` (not yet built) | TBD | feat-TBD | +| PostgreSQL | Real | `in-memory-user-repository.adapter.ts` (unit tests only) | `pg-user-repository.adapter.ts` | Task #2 | feat-001 | + +--- + +## Environment Variable Reference + +| Variable | Value | Effect | +|----------|-------|--------| +| `MOCK_AUTH` | `false` | Clerk JWT verification is live β€” all requests require a valid Clerk session token | +| `MOCK_AUTH` | `true` | For CI/unit tests only β€” `@clerk/express` is mocked at the module level via `vi.mock()` | +| `MOCK_KYC` | `true` | KYC verification calls use `mock-kyc-adapter.ts` β€” always returns stubbed responses | +| `MOCK_PAYMENTS` | `true` | Payment gateway calls use `mock-payment-adapter.ts` β€” no Stripe charges are made | +| `MOCK_EMAIL` | `true` | Email delivery uses `mock-email-adapter.ts` β€” emails are logged to console, not sent via SES | + +--- + +## Notes + +- **Clerk Auth is always a real integration.** It is never mocked in running application code. + The `MOCK_AUTH=true` flag is reserved exclusively for CI/unit test environments where + `@clerk/express` is mocked at the module level using `vi.mock()`. Do not set `MOCK_AUTH=true` + in a deployed environment. + +- **KYC, Payments, and Email** are mocked for the local demo and all non-production environments + until the corresponding real adapters are built and the manual tasks for each third-party service + are completed. + +- **PostgreSQL** is always real. The `in-memory-user-repository.adapter.ts` is used only in unit + tests β€” it is not an application-level mock controlled by an environment variable. + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/prds/feat-001-account-auth.md b/.claude/prds/feat-001-account-auth.md new file mode 100644 index 0000000..0ea8f7a --- /dev/null +++ b/.claude/prds/feat-001-account-auth.md @@ -0,0 +1,72 @@ +## feat-001: Account Registration and Authentication + +**Bounded Context(s):** Account (L4-001) +**Priority:** P0 +**Dependencies:** None +**Estimated Complexity:** M + +### Summary + +This feature implements the full account lifecycle foundation: user registration via Clerk, email verification, role assignment on activation (Backer by default), and profile management (display name, avatar, bio, notification preferences). +It is the identity layer that every other feature depends on β€” no authenticated feature can be built until this exists. + +### Acceptance Criteria + +- [ ] A new user can register with email and password via Clerk; account enters `Pending Verification` state and a verification email is sent. +- [ ] Clicking a valid, non-expired verification link transitions the account to `Active` state. +- [ ] An account in `Active` state is automatically assigned the `Backer` role. +- [ ] A user can register via Google or Microsoft SSO (Clerk OIDC); account enters `Active` state immediately (email pre-verified by provider). +- [ ] Registration with a duplicate email returns an error that does not reveal whether the existing account uses SSO or password. +- [ ] An authenticated user can update their display name, bio, and avatar; changes are persisted and reflected in subsequent profile reads. +- [ ] An authenticated user can read their own profile, including KYC status (sourced from feat-002) and roles. +- [ ] An authenticated user can view and update notification preferences (all categories); security notifications cannot be disabled. +- [ ] All account mutations are logged: timestamp, actor, action, affected resource. +- [ ] Every API endpoint except `/health` rejects unauthenticated requests with HTTP 401. +- [ ] The `GET /me` endpoint returns the authenticated user's profile, roles, and KYC status. + +### User Story + +As a person interested in Mars funding, I want to register an account and log in so that I can back missions and, if I choose, create campaigns. + +### Key Decisions / Open Questions + +- Clerk is the auth provider (defined in tech stack L3-008); the backend validates Clerk JWTs via middleware β€” no custom auth implementation. +- Session management (token lifetime, refresh, revocation) is delegated entirely to Clerk for the local demo. +- Avatar uploads must be served from a separate domain per engineering standard; clarify whether an S3 bucket or Clerk's CDN is used for the workshop demo. +- The `user_id` stored in the platform DB is Clerk's user ID (`clerk_id`) used as the FK across all tables. + +### Out of Scope + +- MFA enrollment/enforcement (theatre for local demo β€” Clerk handles MFA; platform does not build its own TOTP/WebAuthn flows). +- Session elevation for sensitive operations (theatre). +- Account deactivation, GDPR erasure, and data portability (theatre). +- Password reset flow (delegated entirely to Clerk-hosted UI). +- SSO provider management beyond Google and Microsoft. + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/prds/feat-001-design.md b/.claude/prds/feat-001-design.md new file mode 100644 index 0000000..fca04fe --- /dev/null +++ b/.claude/prds/feat-001-design.md @@ -0,0 +1,1304 @@ +# Design Spec: feat-001 β€” Account Registration and Authentication + +> Component-level UI specification. Generated by Design Speccer. +> Feature spec: feat-001-spec.md | feat-001-spec-ui.md +> Design system: specs/standards/brand.md (L2-001) +> Status: Complete + +--- + +## Overview + +feat-001 covers the identity layer of MMF. Registration and login UI is Clerk-hosted β€” no custom design work applies to those screens beyond the page wrapper. MMF-custom design effort concentrates on four surfaces: + +1. **Sign-in/Sign-up page wrappers** β€” MMF page shell hosting the Clerk component +2. **Auth callback / loading page** (`/auth/callback`) β€” transitional state while sync resolves +3. **Onboarding flow** (`/onboarding`) β€” 5-step post-registration profile setup +4. **Settings: Profile** (`/settings/profile`) β€” ongoing profile management +5. **Settings: Notifications** (`/settings/notifications`) β€” notification preference management + +--- + +## Page Layouts + +### 1. Sign-in / Sign-up Pages + +**Routes:** `/sign-in`, `/sign-up` +**Layout:** Single-column, centred +**Max width:** No max-width container β€” full viewport, centred content block + +#### Layout Structure + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ bg: --color-bg-page (void #060A14) full viewport β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ MMF Logo β€” full vertical β”‚ β”‚ +β”‚ β”‚ lockup, 120px coin height β”‚ β”‚ +β”‚ β”‚ (dark variant, centred) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ 48px gap β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Clerk or β”‚ β”‚ +β”‚ β”‚ Clerk β”‚ β”‚ +β”‚ β”‚ component β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ (Clerk-themed, see β”‚ β”‚ +β”‚ β”‚ Clerk customisation notes) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Logo:** Full vertical lockup (coin icon above wordmark). Coin at 120px height. Centred horizontally. Clear space: 8px minimum on all sides around the coin icon per L2-001 Section 6.2. + +**Clerk component theming:** Clerk's `` and `` components accept an `appearance` prop. Configure it to align with MMF's palette as closely as Clerk permits. Target values (Clerk `appearance.variables`): +- `colorBackground`: maps to `--color-bg-surface` (`#0B1628`) +- `colorInputBackground`: maps to `--color-bg-input` (white at 4% opacity) +- `colorInputText`: maps to `--color-text-primary` (`#E8EDF5`) +- `colorText`: maps to `--color-text-primary` +- `colorTextSecondary`: maps to `--color-text-secondary` +- `colorPrimary`: maps to `--color-action-primary` (`#FF5C1A`) +- `borderRadius`: `12px` (aligns with `--radius-input`) +- `fontFamily`: DM Sans (Clerk accepts a font-family string) + +Note: Clerk's hosted UI cannot consume CSS custom properties directly. Provide resolved hex/rgba values in the Clerk `appearance` object. This is the one permitted exception to the Tier 2-only rule β€” Clerk's prop API requires raw values, not CSS variables. + +**Responsive behaviour:** +- All breakpoints: Single column. Clerk component block is max 480px wide, centred. Logo block above. Full viewport background. + +--- + +### 2. Auth Callback / Loading Page + +**Route:** `/auth/callback` +**Layout:** Single-column, centred, full viewport +**Max width:** N/A β€” full viewport + +#### Layout Structure + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ bg: --color-bg-page (void) full viewport β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ MMF coin icon mark only β”‚ β”‚ +β”‚ β”‚ 32px height β”‚ β”‚ +β”‚ β”‚ (centred) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ 24px gap β”‚ +β”‚ Loading spinner (16px, --color-action-primary)β”‚ +β”‚ 16px gap β”‚ +β”‚ "Preparing your mission profile…" β”‚ +β”‚ --type-body, --color-text-secondary β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ ─ ─ ─ ─ ─ ERROR STATE (replaces above) ─ ─ ─ ─ ─ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ MMF coin icon mark 32px β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ 24px gap β”‚ +β”‚ "Something went wrong on our end." β”‚ +β”‚ --type-body, --color-text-primary β”‚ +β”‚ 8px gap β”‚ +β”‚ "We're looking into it. Try again in a few β”‚ +β”‚ minutes." β”‚ +β”‚ --type-body-small, --color-text-secondary β”‚ +β”‚ 24px gap β”‚ +β”‚ [Try again β†’] ← Secondary button β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Responsive behaviour:** +- All breakpoints: Single column, centred content block max 320px wide. + +--- + +### 3. Onboarding Page + +**Route:** `/onboarding` +**Layout:** Single-column, centred content block +**Max width:** 640px centred + +#### Layout Structure + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ bg: --color-bg-page full viewport β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Step indicator: "STEP 1 OF 5" β”‚ β”‚ +β”‚ β”‚ --type-label, --color-text-tertiary β”‚ β”‚ +β”‚ β”‚ Segmented progress bar (5 segments) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ 32px β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Section label: "01 β€” WELCOME" β”‚ β”‚ +β”‚ β”‚ --type-section-label, --color-text-accent β”‚ β”‚ +β”‚ β”‚ 16px β”‚ β”‚ +β”‚ β”‚ Heading (step-specific) β”‚ β”‚ +β”‚ β”‚ --type-page-title (56px, Bebas Neue, UPPER)β”‚ β”‚ +β”‚ β”‚ --color-text-primary β”‚ β”‚ +β”‚ β”‚ 16px β”‚ β”‚ +β”‚ β”‚ Body text (step-specific) β”‚ β”‚ +β”‚ β”‚ --type-body, --color-text-secondary β”‚ β”‚ +β”‚ β”‚ 40px β”‚ β”‚ +β”‚ β”‚ Step content area (form, selections, etc.) β”‚ β”‚ +β”‚ β”‚ 40px β”‚ β”‚ +β”‚ β”‚ CTA row (one primary per step max) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Responsive behaviour:** +- Desktop (β‰₯1024px): 640px centred block, generous vertical breathing room +- Tablet (768–1023px): 100% width with 48px horizontal padding +- Mobile (<768px): 100% width with 24px horizontal padding; page-title drops to `--type-section-heading` (40px, still Bebas Neue, still uppercase) + +--- + +### 4. Settings: Profile Page + +**Route:** `/settings/profile` +**Layout:** Two-column settings layout (settings nav left, content right) +**Max width:** 980px centred + +#### Layout Structure + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Nav bar (existing AppShell nav β€” no changes) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ β”‚ +β”‚ Settings nav β”‚ Profile content area β”‚ +β”‚ (~220px fixed) β”‚ (remaining width, max ~720px) β”‚ +β”‚ β”‚ β”‚ +β”‚ - Profile β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ - Notifications β”‚ β”‚ Section label: "01 β€” PROFILE" β”‚ β”‚ +β”‚ β”‚ β”‚ Page title: "YOUR PROFILE" β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ 24px β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ ProfileCard component β”‚ β”‚ +β”‚ β”‚ β”‚ (avatar, name, email, roles, β”‚ β”‚ +β”‚ β”‚ β”‚ KYC badge) β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ 24px β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ ProfileEditForm component β”‚ β”‚ +β”‚ β”‚ β”‚ (display name, bio inputs) β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Responsive behaviour:** +- Desktop (β‰₯1024px): Two-column as shown. Settings nav fixed left. +- Tablet (768–1023px): Settings nav collapses to a horizontal tab bar above content. Full-width content below. +- Mobile (<768px): No settings nav visible. Tabs replaced by a back-navigation header row ("← Settings"). Content is single-column, full width with 24px padding. + +--- + +### 5. Settings: Notifications Page + +**Route:** `/settings/notifications` +**Layout:** Same two-column settings layout as Profile page +**Max width:** 980px centred + +#### Layout Structure + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Nav bar β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ β”‚ +β”‚ Settings nav β”‚ Notifications content area β”‚ +β”‚ (~220px fixed) β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Section label: "02 β€” COMMS" β”‚ β”‚ +β”‚ β”‚ β”‚ Page title: "NOTIFICATIONS" β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ 24px β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ NotificationPrefsForm β”‚ β”‚ +β”‚ β”‚ β”‚ (6 toggle rows) β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Responsive behaviour:** Identical to Settings: Profile page responsive rules. + +--- + +## Components + +### Component: OnboardingStepIndicator + +**File:** `packages/frontend/src/components/onboarding/OnboardingStepIndicator.tsx` +**Reuses:** New component + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `currentStep` | `number` | Yes | β€” | Current step (1–5) | +| `totalSteps` | `number` | No | `5` | Total number of steps | + +#### Visual Specification + +**Container:** +- Layout: flex column, gap 8px +- No background + +**Step label:** +- Content: `"STEP {currentStep} OF {totalSteps}"` +- Font: `--type-label` (Space Mono 400, 11px, letter-spacing 0.2em, uppercase) +- Colour: `--color-text-tertiary` +- Margin bottom: 8px + +**Progress bar track:** +- Layout: flex row, gap 4px +- Height: 4px +- Track segments: 5 equal-width segments + +**Segment β€” completed:** +- Background: `--color-action-primary` (`#FF5C1A`) +- Border radius: `--radius-progress` (full) +- Height: 4px +- Transition: background-color `--motion-hover` on step change + +**Segment β€” current:** +- Background: `--gradient-action-primary` +- Border radius: `--radius-progress` +- Height: 4px + +**Segment β€” upcoming:** +- Background: `--color-progress-track` (white at 6% opacity) +- Border radius: `--radius-progress` +- Height: 4px + +#### Accessibility + +- **Role:** `progressbar` +- **aria-valuenow:** `currentStep` +- **aria-valuemin:** `1` +- **aria-valuemax:** `totalSteps` +- **aria-label:** `"Onboarding progress: step {currentStep} of {totalSteps}"` +- Individual segments are decorative: `aria-hidden="true"` on each `
` segment + +--- + +### Component: OnboardingWelcomeStep + +**File:** `packages/frontend/src/components/onboarding/steps/OnboardingWelcomeStep.tsx` +**Reuses:** New component (composes Button primitive) + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `onNext` | `() => void` | Yes | β€” | Advances to step 2 | + +#### Visual Specification + +**Section label:** +- Content: `"01 β€” WELCOME"` +- Font: `--type-section-label` (Space Mono 400, 11px, letter-spacing 0.3em, uppercase) +- Colour: `--color-text-accent` +- Margin bottom: 16px + +**Heading:** +- Content: `"YOUR MISSION STARTS HERE."` (always uppercase β€” Bebas Neue) +- Font: `--type-page-title` (Bebas Neue 400, 56px, letter-spacing 0.04em) +- Colour: `--color-text-primary` +- Margin bottom: 16px + +**Body text:** +- Content: `"Welcome to Mars Mission Fund. Back the missions that matter, support the engineering that gets us there, and be part of the most ambitious journey humanity has ever taken."` +- Font: `--type-body` (DM Sans 400, 16px, line-height 1.7) +- Colour: `--color-text-secondary` +- Margin bottom: 40px + +**Primary CTA button:** +- Label: `"Get Started"` +- Variant: Primary (`--gradient-action-primary`, `--color-action-primary-text`) +- Radius: `--radius-button` (full pill) +- Padding: 12px 24px +- Font: `--type-button` (DM Sans 600, 14px, letter-spacing 0.01em) +- Shadow: `0 0 20px --color-action-primary-shadow` +- Animation entry: `--motion-enter-emphasis` (overshoot bounce; instant fade on `prefers-reduced-motion`) + +#### Accessibility + +- CTA button: focusable, `type="button"`, descriptive label (no "click here") +- Heading is a semantic `

` (page-level heading for the onboarding route) + +--- + +### Component: OnboardingRoleStep + +**File:** `packages/frontend/src/components/onboarding/steps/OnboardingRoleStep.tsx` +**Reuses:** New component + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `onNext` | `() => void` | Yes | β€” | Advances to step 3 | +| `onBack` | `() => void` | Yes | β€” | Returns to step 1 | + +#### Visual Specification + +**Section label:** +- Content: `"02 β€” YOUR ROLE"` +- Font: `--type-section-label` +- Colour: `--color-text-accent` +- Margin bottom: 16px + +**Heading:** +- Content: `"HOW WILL YOU JOIN THE MISSION?"` +- Font: `--type-page-title` +- Colour: `--color-text-primary` +- Margin bottom: 16px + +**Body text:** +- Content: `"Tell us how you plan to participate. You can always do both."` +- Font: `--type-body` +- Colour: `--color-text-secondary` +- Margin bottom: 32px + +**Role selection cards (3 total):** +Layout: vertical stack, gap 16px + +Each card: +- Background: `--color-bg-surface` +- Border: 1px solid `--color-border-subtle` +- Border radius: `--radius-card` (20px) +- Padding: 24px +- Cursor: pointer +- Interactive β€” see hover and selected states below + +Card content: +- Icon: inline SVG, 24px, `currentColor`, `--color-text-secondary` default / `--color-action-primary-hover` when selected +- Title: DM Sans 700, 18px, `--color-text-primary` +- Description: `--type-body-small`, `--color-text-secondary` + +| Card | Title | Description | +|------|-------|-------------| +| Backer | "Back Missions" | "Discover and fund projects moving humanity closer to Mars." | +| Creator | "Create a Campaign" | "Raise capital for your Mars-enabling technology or mission." | +| Both | "Back and Create" | "Contribute to existing missions and launch your own." | + +**Default state:** +- Border: 1px solid `--color-border-subtle` +- Background: `--color-bg-surface` + +**Hover state:** +- Background: `--color-bg-elevated` (`#0E2040`) +- Border: 1px solid `--color-border-emphasis` +- Transition: background-color, border-color `--motion-hover` + +**Selected state:** +- Border: 2px solid `--color-action-primary` +- Background: `--color-bg-elevated` +- Box shadow: `0 0 0 3px rgba(255,92,26,0.15)` (matches focus style pattern from L2-001 Section 5.3) +- Icon colour: `--color-action-primary-hover` +- Card title: `--color-text-primary` (no change) + +**CTA row:** +- Primary button: `"Continue β†’"` β€” enabled only when a selection is made +- Ghost button: `"Back"` β€” left of primary, or below on mobile +- Layout: flex row, space-between or `justify-end` with ghost on left + +#### Accessibility + +- Each card: `role="radio"`, part of a `role="radiogroup"` with `aria-label="Select your role"` +- Selected card: `aria-checked="true"`, unselected: `aria-checked="false"` +- Keyboard: Space/Enter to select; arrow keys to move between options +- Focus indicator on each card: `border-color: --color-action-primary; box-shadow: 0 0 0 3px rgba(255,92,26,0.15)` (per L2-001 Section 5.3) +- Screen reader reads card title + description when focused + +--- + +### Component: OnboardingProfileStep + +**File:** `packages/frontend/src/components/onboarding/steps/OnboardingProfileStep.tsx` +**Reuses:** New component (composes FormInput primitive) + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `onNext` | `(data: { displayName: string; bio: string }) => void` | Yes | β€” | Saves and advances | +| `onBack` | `() => void` | Yes | β€” | Returns to step 2 | +| `onSkip` | `() => void` | Yes | β€” | Skips profile step | +| `isSaving` | `boolean` | No | `false` | Disables save button during mutation | +| `initialValues` | `{ displayName: string \| null; bio: string \| null }` | No | `{ displayName: null, bio: null }` | Pre-fills form from existing profile | + +#### Visual Specification + +**Section label:** +- Content: `"03 β€” YOUR PROFILE"` +- Font: `--type-section-label` +- Colour: `--color-text-accent` +- Margin bottom: 16px + +**Heading:** +- Content: `"TELL US ABOUT YOURSELF."` +- Font: `--type-page-title` +- Colour: `--color-text-primary` +- Margin bottom: 16px + +**Body text:** +- Content: `"Your profile appears on campaigns you back and missions you create. You can always update it later."` +- Font: `--type-body` +- Colour: `--color-text-secondary` +- Margin bottom: 32px + +**Form fields:** + +Display Name input: +- Label: `"DISPLAY NAME"` β€” `--type-input-label` (Space Mono 600, 12px, letter-spacing 0.05em, uppercase), `--color-text-tertiary` +- Input: `--color-bg-input`, `--color-border-input`, `--radius-input` (12px), `--color-text-primary`, padding 14px 16px +- Placeholder: `"Ada Lovelace"` β€” `--color-text-tertiary` +- Focus: `border-color: --color-action-primary; box-shadow: 0 0 0 3px rgba(255,92,26,0.25)` +- Helper text below: `"Up to 255 characters"` β€” `--type-body-small`, `--color-text-tertiary` +- Error state: border `--color-status-error`, error message `--type-body-small`, `--color-text-error` below input, associated via `aria-describedby` +- Margin bottom: 24px + +Bio textarea: +- Label: `"BIO"` β€” same label style as display name +- Textarea: same background/border/radius/padding as input; min-height 120px, resize: vertical +- Placeholder: `"Mars enthusiast and propulsion engineer…"` β€” `--color-text-tertiary` +- Focus: same focus ring as input +- Character count: `"0 / 500"` right-aligned below, `--type-label`, `--color-text-tertiary`; turns `--color-text-error` when > 450 (warning) and at 500 (limit) +- Error state: same pattern as display name input +- Margin bottom: 32px + +**CTA row (flex, space-between):** +- Left: Ghost button `"Back"` β†’ `--color-action-ghost-text`, `--color-action-ghost-border`, `--radius-button` +- Centre: Ghost button `"Skip for now"` β†’ same ghost style +- Right: Primary button `"Save and continue β†’"` β†’ disabled and styled with `--color-action-disabled` while `isSaving === true` + +Layout note: On mobile, stack vertically (Skip top, then Save bottom, Back as tertiary text link below). + +#### States + +**Default:** Form with empty or pre-filled inputs. + +**Loading (isSaving):** Save button shows spinner icon (16px, `currentColor`) left of label text `"Saving…"`. Button is `disabled` and visually `--color-action-disabled`. Inputs remain interactive. + +**Error (API failure):** Inline error banner above CTA row. Background: `--color-status-error` at 12% opacity, border: `--color-status-error` at 20% opacity, text: `"We couldn't save your profile. Try again."` β€” `--type-body-small`, `--color-text-error`. + +**Success:** No explicit success state β€” component calls `onNext()` immediately after successful mutation, advancing the step. + +#### Accessibility + +- Form: `
` element, `onSubmit` handler +- All inputs: `