Age & Identity Verification — Technical Reference
← Back to Age & Identity Verification
This covers two distinct compliance mechanisms, both deliberately separate from the notability verification badge (that badge is about authenticity/notoriety and has nothing to do with age or KYC):
- Age — enforced once, at signup, from the account's date of birth.
- Identity (KYC) — a manual, admin-reviewed government-ID + selfie flow that gates real-money cashouts.
Where this lives
Backend — age
apps/backend/validators/user.validator.js—validateDateOfBirthenforces a minimum age of 18 (raised from 13 in this pass), andvalidateRegistrationInputnow requiresdateOfBirth(previously optional). This was a real gap: thedate_of_birthcolumn andvalidateDateOfBirthboth already existed, but registration treated the field as optional and the signup form never collected it, so in practice no age check ever ran end to end.apps/backend/database/models/user.js— the pre-existingdateOfBirth(date_of_birth,DATEONLY) attribute; unchanged.
Backend — identity
apps/backend/managers/user-managers/identity-verification.manager.js—submitRequest,getStatus,getPendingRequests,approve,reject, andrequireApprovedForCashout(the guard the cashout manager calls).apps/backend/validators/identity-verification.validator.js—validateSubmitInput(both URLs required),validateRejectionReason(required, non-empty).apps/backend/graphql/types/identity-verification.type.js+resolvers/identity-verification.resolver.js— the GraphQL surface (queries/mutations below). Admin mutations gate oncontext.admin+adminUserManager.hasPermission(admin.adminId, 'MODERATE_CONTENT'), the same pattern as product disputes and post-purchase refunds.apps/backend/database/models/user.js— new columnsidentityVerificationStatus(enumnone|pending|approved|rejected),identityDocumentUrl,identitySelfieUrl,identityVerificationRequestedAt,identityVerifiedAt,identityVerifiedByAdminId,identityRejectionReason. Deliberately not reusing theisVerified/verificationStatus/verificationCategorycolumns on the same table — those back the notability badge, a different claim reviewed by different criteria.apps/backend/database/migrations/20260717070000-add-identity-verification-columns.js— idempotent (describeTableguards), matching the established migrations pattern.apps/backend/managers/coin-managers/coin-cashout.manager.js— bothrequestCashout(Stripe) andrequestManualCashout(Mexico CLABE/RFC) now callidentityVerificationManager.requireApprovedForCashout(userId, context)up front, so a user with unapproved identity can't request a payout.apps/backend/validators/notification.validator.js—identity_verification_approved/identity_verification_rejectedadded to the notification-type whitelist.
Frontend (frontend-nextjs)
apps/frontend-nextjs/src/components/Login.tsx— the registration form now has a required date-of-birth field with a client-side 18+ check (the backend enforces it too) and passesdateOfBirthin theRegistermutation input.apps/frontend-nextjs/src/page-components/settings/IdentityVerificationPage.tsx(routed at/settings/identity-verification) — uploads the ID photo + selfie to the existing REST/uploadendpoint (no media-upload GraphQL mutation exists), callssubmitIdentityVerification, and renders the caller's own status (none/pending/approved/rejected + rejection reason, with resubmit after a rejection) frommyIdentityVerificationStatus. Linked from the settings menu inSettingsPage.tsx.apps/frontend-nextjs/src/page-components/PaymentsPage.tsx— the Payouts tab shows an identity-verification gate banner (with a link to the settings page, or a "under review" state) whenever the caller's identity isn't approved, so the backend cashout gate isn't a surprise.apps/frontend-nextjs/src/page-components/settings/PayoutsPage.tsx(routed at/settings/payouts, linked from the settings menu) — a newer, dedicated payouts settings page that shows the same identity-verification gate banner.
Frontend (frontend-admin)
apps/frontend-admin/src/app/moderation/identity-verifications/page.tsx— the review queue: listsadminPendingIdentityVerifications, opens a modal showing the submitted document + selfie, and approves (adminApproveIdentityVerification) or rejects with a required reason (adminRejectIdentityVerification). Gated onMODERATE_CONTENT(super_admin bypasses), mirroring the server-side check. Linked fromAdminLayout.tsx.
Technical implementation checklist
- Minimum age 18 enforced +
dateOfBirthrequired at registration —user.validator.js, and the signup form inLogin.tsxnow collects it -
submitIdentityVerification—identity-verification.resolver.js+IdentityVerificationPage.tsx; document + selfie uploaded via/upload, resubmittable after a rejection -
myIdentityVerificationStatus— status screen inIdentityVerificationPage.tsxand the payouts-tab gate banner inPaymentsPage.tsx -
adminPendingIdentityVerifications/adminApproveIdentityVerification/adminRejectIdentityVerification— admin queue infrontend-admin,MODERATE_CONTENT-gated - Cashout gate —
coin-cashout.manager.jsrequestCashout/requestManualCashoutrequire approved identity viarequireApprovedForCashout - Automated/third-party ID verification (Stripe Identity, Persona, etc.) — not built; fully manual/admin-reviewed today
- Age re-verification beyond the signup date-of-birth check — not built
GraphQL API
Defined in graphql/types/identity-verification.type.js, resolved in graphql/resolvers/identity-verification.resolver.js.
The document and selfie must be uploaded to the REST /upload endpoint first (same as every other media upload in this codebase); the resulting URLs are passed into submitIdentityVerification. Submitting sets the caller's status to pending; an admin then approves or rejects. A rejected user can resubmit, which returns them to pending and clears the old rejection reason.
# The caller's own status
query MyIdentityVerificationStatus {
myIdentityVerificationStatus {
identityVerificationStatus # none | pending | approved | rejected
identityVerificationRequestedAt
identityVerifiedAt
identityRejectionReason
}
}
# Submit / resubmit (documentUrl + selfieUrl are S3 URLs from /upload)
mutation SubmitIdentityVerification($input: IdentityVerificationSubmitInput!) {
submitIdentityVerification(input: $input) {
identityVerificationStatus
identityVerificationRequestedAt
}
}
# Admin review queue (requires MODERATE_CONTENT)
query PendingIdentityVerifications($limit: Int, $offset: Int) {
adminPendingIdentityVerifications(limit: $limit, offset: $offset) {
id username email firstName lastName profilePicture
identityVerificationStatus
identityDocumentUrl
identitySelfieUrl
identityVerificationRequestedAt
}
}
# Admin decisions (require MODERATE_CONTENT)
mutation ApproveIdentityVerification($userId: ID!) {
adminApproveIdentityVerification(userId: $userId) { identityVerificationStatus }
}
mutation RejectIdentityVerification($userId: ID!, $reason: String!) {
adminRejectIdentityVerification(userId: $userId, reason: $reason) { identityVerificationStatus }
}
Data model (columns on users)
| Column | Description |
|---|---|
identity_verification_status | none | pending | approved | rejected |
identity_document_url | S3 URL of the submitted government ID photo |
identity_selfie_url | S3 URL of the submitted selfie |
identity_verification_requested_at | When the (most recent) request was submitted |
identity_verified_at | When an admin approved it |
identity_verified_by_admin_id | The admin who approved/rejected (FK → admin_user) |
identity_rejection_reason | Reason shown to the user on rejection |
Cashout gate
coin-cashout.manager.js#requireApprovedForCashout(userId) throws errors.identity_verification.not_approved unless the user's identity_verification_status is approved. It's called at the top of both requestCashout (Stripe Connect) and requestManualCashout (Mexico CLABE/RFC), so neither payout flow can be initiated without an approved identity. Buying coins, receiving tips/subscriptions, and selling shop products are not gated — only converting coins back to real money is.