Appearance
Epic 1 — Authentication & Onboarding
BE-AUTH-001 — Social login (Google, Facebook, LinkedIn)
- [x] Implemented
Files:
- Create:
src/services/auth/socialLogin.ts - Edit:
src/graphql/schema/index.ts— addsocialLogin(provider: String!, idToken: String!): AuthPayload! - Edit:
src/graphql/resolvers/auth.ts— addsocialLoginresolver
Description: Accept a provider ID token from the frontend (issued by Firebase Auth or Auth0). Verify the token server-side, find or create the user in PostgreSQL, return a CasTyou JWT pair. Supported providers: GOOGLE, FACEBOOK, LINKEDIN.
Acceptance criteria:
- New users are created with
roledetermined by the onboarding step (not hardcoded) - Existing social users receive a fresh token pair
- Invalid/expired provider tokens throw
UNAUTHENTICATED
BE-AUTH-002 — Two-Factor Authentication (2FA)
- [x] Implemented
Files:
- Create:
src/services/auth/twoFactor.ts— TOTP generation/verification (useotplib) - Edit:
prisma/schema.prisma— addtwoFactorSecret String?,twoFactorEnabled Boolean @default(false)toUser - Edit:
src/graphql/schema/index.ts— addenable2FA,verify2FA,disable2FAmutations; extendLoginResult - Edit:
src/graphql/resolvers/auth.ts
Description: TOTP-based 2FA. enable2FA returns a QR code URL and backup codes. verify2FA(code) confirms setup. Login returns a requiresTwoFactor: Boolean flag when 2FA is enabled; client must call verify2FALogin(tempToken, code) to get the full JWT pair.
FE-AUTH-001 — Social login buttons (app)
- [x] Implemented
Files:
- Edit:
apps/app/src/pages/auth/LoginPage.tsx - Edit:
apps/app/src/pages/auth/RegisterPage.tsx - Create:
apps/app/src/lib/socialAuth.ts— Firebase client init
Description: Add Google, Facebook, LinkedIn sign-in buttons below the email/password form. Use Firebase Auth JS SDK to get the provider ID token, then call the socialLogin GraphQL mutation. Store the resulting JWT in Zustand auth store.
FE-AUTH-002 — Onboarding flow (role selection + profile bootstrap)
- [x] Implemented
Files:
- Create:
apps/app/src/pages/onboarding/OnboardingPage.tsx - Create:
apps/app/src/pages/onboarding/RoleSelectStep.tsx - Create:
apps/app/src/pages/onboarding/TalentBootstrapStep.tsx - Create:
apps/app/src/pages/onboarding/ProducerBootstrapStep.tsx - Edit:
apps/app/src/App.tsx— add/onboardingroute, redirect new users there
Description: After first login (or social auth), if the user has no profile yet, redirect to /onboarding. Step 1: choose role (Talent / Producer / Agency). Step 2: collect minimum required fields for that role (Talent: displayName, primaryCategory, location; Producer: industryRole, companyName). On completion, create the profile via GraphQL and redirect to the main app.
All step components must use @castyou/design-system components only.
BE-VERIFY-001 — Identity liveness verification (Verified badge)
- [x] Implemented
Files:
- Create:
src/services/auth/liveness.ts— challenge token generation + HMAC validation - Create:
src/services/auth/faceComparison.ts— HTTP client to the face-comparison sidecar - Edit:
prisma/schema.prisma— add fields toUser,TalentProfile,ProducerProfile - Run:
pnpm db:migrate - Edit:
src/graphql/schema/index.ts— addcreateLivenessChallenge,confirmLivenessmutations - Create:
src/graphql/resolvers/verification.ts - Edit:
src/graphql/resolvers/index.ts - Create:
face-comparison/— standalone Python/FastAPI sidecar (separate repo or subdirectory with its ownDockerfile)
Description: Optional one-time identity check that awards a permanent Verified badge. Fully self-hosted on Hetzner — no external API. Built on two parts:
- Browser-side liveness (MediaPipe FaceMesh, runs as WebAssembly — no server GPU needed): issues a short randomised movement challenge (e.g. look left → look right → blink), detects completion via facial landmark tracking, and captures a still frame when the challenge passes.
- Server-side face comparison (Python/FastAPI sidecar using
face_recognition, dlib-based, CPU-only, ~300 MB RAM): compares the captured liveness frame against the user's existing profile photo.
Challenge flow:
- Client calls
createLivenessChallenge→ backend generates a time-limited HMAC-signed token containing a random challenge sequence (e.g.["LOOK_LEFT", "LOOK_RIGHT", "BLINK"]) and returns it - Frontend runs MediaPipe FaceMesh, verifies each step of the challenge in order, captures a JPEG frame on success
- Client calls
confirmLiveness(challengeToken, capturedFrameBase64) - Backend validates the HMAC token (expiry + user binding), forwards the frame + profile photo URL to the face-comparison sidecar
- Sidecar returns
{ match: boolean, distance: float }— accept ifdistance ≤ 0.5 - On success: set
User.verifiedAt, award "Verified" badge
Prisma additions:
prisma
// on User
verifiedAt DateTime?
// on TalentProfile and ProducerProfile
verifiedAt DateTime? // denormalised for fast feed queriesGraphQL:
graphql
type LivenessChallenge {
token: String! # HMAC-signed, expires in 5 min
steps: [String!]! # e.g. ["LOOK_LEFT", "LOOK_RIGHT", "BLINK"]
}
type VerificationResult {
success: Boolean!
verifiedAt: String
error: String # FACE_MISMATCH | TOKEN_EXPIRED | NO_PROFILE_PHOTO | CHALLENGE_FAILED
}
createLivenessChallenge: LivenessChallenge!
confirmLiveness(token: String!, frame: String!): VerificationResult!Face-comparison sidecar (face-comparison/):
face-comparison/
main.py # FastAPI app, POST /compare { frame_b64, photo_url } → { match, distance }
requirements.txt # face_recognition, fastapi, uvicorn, Pillow, requests
Dockerfile # python:3.11-slim, installs dlib + face_recognition (CPU build)Runs as a separate Coolify service on the same Docker network. Express BE calls it via internal hostname (e.g. http://face-comparison:8001/compare) — never exposed to the internet.
Security rules:
- Challenge token is HMAC-SHA256 signed with
LIVENESS_SECRETenv var; includesuserId+expiresAt+ randomnonce - Token expires after 5 minutes
- One valid token per user at a time (store nonce in Redis, invalidate on use)
confirmLivenessrejects tokens not belonging to the requesting user- The captured frame is never persisted — used only for the comparison call, then discarded
- Re-verification is allowed (e.g. after profile photo change) — overwrites
verifiedAt - Require profile photo to exist before verification can be attempted; return
NO_PROFILE_PHOTOerror otherwise
RAM impact on Hetzner CX22: sidecar uses ~300 MB idle. Current headroom is ~2 GB, so fits comfortably. Upgrade to CX32 only if overall usage exceeds 3 GB.
Environment variables needed:
env
LIVENESS_SECRET=<random-secret> # for HMAC signing
FACE_COMPARISON_URL=http://face-comparison:8001 # internal Docker networkAcceptance criteria:
- Face mismatch →
success: false, error: "FACE_MISMATCH" - Expired / tampered token →
success: false, error: "TOKEN_EXPIRED" - No profile photo →
success: false, error: "NO_PROFILE_PHOTO" - Verified users show a shield/checkmark badge on
TalentCard, profile pages, and job applications - Non-verified users can still use the platform fully — verification is optional
FE-VERIFY-001 — Liveness verification UI
- [x] Implemented
Files:
- Create:
apps/app/src/pages/verify/VerifyIdentityPage.tsx - Create:
apps/app/src/components/LivenessChallenge.tsx— MediaPipe camera component - Create:
apps/app/src/hooks/useLivenessVerification.ts - Edit:
apps/app/src/pages/profile/ProfilePage.tsx— add "Get Verified" banner whenverifiedAtis null - Edit:
apps/app/src/App.tsx— add route/verify
Dependencies to add:
bash
pnpm --filter @castyou/app add @mediapipe/tasks-visionDescription: Verification page accessible from the profile page banner and settings. Shows a brief explanation ("Verify your identity to earn a Verified badge — producers trust verified talent"), then runs the liveness challenge in-browser using MediaPipe FaceMesh (no camera stream leaves the device until the final still frame is sent).
Flow:
- Page mounts → calls
createLivenessChallengemutation → gets{ token, steps } LivenessChallengecomponent opens the camera, loads MediaPipe FaceMesh (WebAssembly), and guides the user through each step with an animated overlay (arrow for look directions, eye icon for blink)- On each step completion (detected via landmark deltas), advances to the next; captures a JPEG frame when all steps are done
- Calls
confirmLiveness(token, frameBase64)mutation - Success → show confetti / success state, update
verifiedAtin Zustand store, badge appears immediately - Failure → show friendly retry prompt; allow max 3 attempts per page load (generate a fresh token each time)
LivenessChallenge component is the exception to the DS rule — it owns the raw camera/ML logic. All surrounding UI (banner, result states, CTA buttons) must use @castyou/design-system.
Acceptance criteria:
- Works on mobile browsers (Chrome, Safari) via the MediaStream API
- Gracefully handles camera permission denial with a clear explanation and a link to browser settings
- "Get Verified" banner is dismissible but reappears after 7 days if still unverified
- Verified badge visible immediately after success without page reload
- MediaPipe WASM loads lazily (not on app startup) to avoid bundle bloat