CNI Upload and AI Analysis

Summary

After registering, a Pro must upload their French national identity document (CNI) within 72 hours. The file is validated (format, magic bytes, size), stored privately outside the web root, and then analysed asynchronously by Claude Vision. The AI extracts identity data, crosschecks it against the declared name, and posts the result to the pro’s Slack thread. Admins then perform manual verification.

State Diagram

stateDiagram-v2
    state "En attente de CNI" as Pending
    state "Accès bloqué (72h)" as Gated
    state "Téléversement en cours" as Uploading
    state "Format refusé" as Rejected_Format
    state "Taille refusée" as Rejected_Size
    state "Analyse en cours" as Processing
    state "CNI valide (IA)" as Valid
    state "CNI invalide (IA)" as Invalid
    state "Erreur analyse IA" as AnalysisError
    state "En attente admin" as AwaitingAdmin
    state "Identité vérifiée" as Verified

    [*] --> Pending: Pro registered, cniVerifiedAt = null
    Pending --> Gated: 72h elapsed, no upload
    Gated --> Uploading: Pro submits file (gate bypassed for this route)
    Pending --> Uploading: Pro submits file within 72h
    Uploading --> Rejected_Format: unsupported MIME or invalid magic bytes
    Uploading --> Rejected_Size: file exceeds CNI_MAX_SIZE_MB
    Uploading --> Processing: file stored, cniAnalysisStatus = "processing"
    Processing --> Valid: Claude crosscheck passes (status = "valid")
    Processing --> Invalid: Claude crosscheck fails (status = "invalid")
    Processing --> AnalysisError: Claude API failure
    Valid --> AwaitingAdmin: admin reviews in admin panel
    Invalid --> AwaitingAdmin
    AnalysisError --> AwaitingAdmin
    AwaitingAdmin --> Verified: admin runs verify-cni → [[cni-verification]]

Steps

1. Check Eligibility (Actor: system)

On every authenticated request the authenticatePro hook enforces the 72h CNI gate. If cniFilePath is null and createdAt is more than 72h ago, all routes except those in CNI_GATE_WHITELIST (/pro/auth/refresh, /pro/auth/logout, /pro/profile/cni-upload) return 403 CNI_REQUIRED.

Triggers: Any authenticated Pro request Outcome: Request blocked or passes through

2. Upload File (Actor: pro)

POST /pro/profile/cni-upload (multipart, rate-limited to 5 requests per hour per proId). Accepted MIME types: image/jpeg, image/png, application/pdf.

If the Pro’s cniVerifiedAt is already set → 409 CNI_ALREADY_VERIFIED (no re-upload allowed without support contact).

Triggers: Pro selects file and submits Outcome: File buffer available for validation

3. Validate File (Actor: system)

Checks in order:

  1. File presence — 400 NO_FILE if absent
  2. MIME type — 400 INVALID_FORMAT if not JPEG/PNG/PDF
  3. Buffer size — 413 FILE_TOO_LARGE if over CNI_MAX_SIZE_MB
  4. Magic bytes — 400 INVALID_IMAGE or INVALID_PDF if header bytes don’t match declared MIME

Triggers: File received Outcome: Buffer accepted or request rejected

4. Store File (Actor: system)

Images (JPEG/PNG) are saved via saveCniImage — stored as {proId}.webp in UPLOAD_PRIVATE_DIR (outside web root). PDFs are rasterized to WebP via saveCniFromPdf (capped at MAX_PDF_PAGES pages). On storage error → 500 IMAGE_PROCESSING_FAILED or PDF_RASTERIZE_FAILED.

Triggers: Validation passes Outcome: File on disk at deterministic path

5. Update DB (Compare-and-Set) (Actor: system)

prisma.pro.updateMany with where: { id, cniVerifiedAt: null } sets cniFilePath, cniAnalysisStatus = "processing", clears cniAnalysisConfidence and cniAnalysisResult. If count === 0, the CNI was verified in a concurrent admin action — the orphan file is cleaned up and 409 CNI_ALREADY_VERIFIED is returned.

Triggers: File stored Outcome: DB row updated atomically

6. Slack Notification (Actor: system)

notifyProCniUploaded posts to #pro-registration in the pro’s existing Slack thread (slackThreadTs) — “CNI prête à vérifier” with a link to the admin fiche. Fire-and-forget.

Triggers: DB updated successfully Outcome: Admin team alerted

7. AI Analysis (Actor: system)

analyzeCni(proId) is called via setImmediate (non-blocking). It reads the stored WebP, base64-encodes it, and calls claude-sonnet-4-6-20250514 with the extract_cni_data tool (forced tool use). The extracted CniExtraction is crosschecked against the declared firstName / lastName:

  • Name normalisation: strips accents, lowercases, removes hyphens and spaces
  • First name: partial match allowed (CNI may include multiple first names)
  • Last name: must match exactly after normalisation
  • Expiry: flags expired documents
  • isIdentityDocument: false → mismatch
  • Image quality "poor" → mismatch

On failure: cniAnalysisStatus = "error". Results (including mismatches) are stored in cniAnalysisResult (JSON) and cniAnalysisConfidence.

Triggers: setImmediate after 200 response sent Outcome: cniAnalysisStatus updated to "valid" or "invalid" or "error"; Slack thread reply posted via notifyCniAnalysis

Error States

  • No file in request → 400 NO_FILE
  • Unsupported MIME type → 400 INVALID_FORMAT
  • File too large → 413 FILE_TOO_LARGE
  • Invalid magic bytes (JPEG) → 400 INVALID_IMAGE
  • Invalid magic bytes (PNG) → 400 INVALID_IMAGE
  • Invalid magic bytes (PDF) → 400 INVALID_PDF
  • Storage failure → 500 IMAGE_PROCESSING_FAILED or PDF_RASTERIZE_FAILED
  • Already verified (pre-check) → 409 CNI_ALREADY_VERIFIED
  • Already verified (CAS failure) → 409 CNI_ALREADY_VERIFIED
  • CNI gate active → 403 CNI_REQUIRED (on other routes)