Skip to main content

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):

  1. Age — enforced once, at signup, from the account's date of birth.
  2. 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.jsvalidateDateOfBirth enforces a minimum age of 18 (raised from 13 in this pass), and validateRegistrationInput now requires dateOfBirth (previously optional). This was a real gap: the date_of_birth column and validateDateOfBirth both 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-existing dateOfBirth (date_of_birth, DATEONLY) attribute; unchanged.

Backend — identity

Frontend (frontend-nextjs)

Frontend (frontend-admin)

  • apps/frontend-admin/src/app/moderation/identity-verifications/page.tsx — the review queue: lists adminPendingIdentityVerifications, opens a modal showing the submitted document + selfie, and approves (adminApproveIdentityVerification) or rejects with a required reason (adminRejectIdentityVerification). Gated on MODERATE_CONTENT (super_admin bypasses), mirroring the server-side check. Linked from AdminLayout.tsx.

Technical implementation checklist

  • Minimum age 18 enforced + dateOfBirth required at registration — user.validator.js, and the signup form in Login.tsx now collects it
  • submitIdentityVerificationidentity-verification.resolver.js + IdentityVerificationPage.tsx; document + selfie uploaded via /upload, resubmittable after a rejection
  • myIdentityVerificationStatus — status screen in IdentityVerificationPage.tsx and the payouts-tab gate banner in PaymentsPage.tsx
  • adminPendingIdentityVerifications / adminApproveIdentityVerification / adminRejectIdentityVerification — admin queue in frontend-admin, MODERATE_CONTENT-gated
  • Cashout gate — coin-cashout.manager.js requestCashout / requestManualCashout require approved identity via requireApprovedForCashout
  • 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)

ColumnDescription
identity_verification_statusnone | pending | approved | rejected
identity_document_urlS3 URL of the submitted government ID photo
identity_selfie_urlS3 URL of the submitted selfie
identity_verification_requested_atWhen the (most recent) request was submitted
identity_verified_atWhen an admin approved it
identity_verified_by_admin_idThe admin who approved/rejected (FK → admin_user)
identity_rejection_reasonReason 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.