Skip to content

ADR: Privy Migration for EmProps Studio + Unified User Table

Date: 2026-02-20 Status: Proposed Author: Architecture Team Builds On:

Context

The miniapp already has Privy integration via upstream sync (Farcaster login creates Privy accounts). The goal is to:

  1. Create a unified user table that bridges identities across apps (Privy ID, Dynamic ID, Farcaster ID)
  2. Replace Dynamic Labs with Privy in EmProps Studio
  3. Migrate existing Dynamic users to Privy accounts

Current State

  • EmProps Studio: Dynamic Labs SDK (@dynamic-labs/sdk-react-core v4.25.7) with SIWE
  • Miniapp: Farcaster Mini App SDK, upstream branch has Privy for Farcaster login
  • Monitor: Already has Privy integration (@privy-io/react-auth v3.10.0)
  • Database: No universal user table. miniapp_user (Farcaster-based), profile, social_link exist separately

Key Files (Current Dynamic Implementation)

FilePurpose
apps/emprops-studio/components/ClientOnlyDynamicProvider.tsxDynamic provider wrapper
apps/emprops-studio/components/SSRSafeDynamicProvider.tsxSSR-safe Dynamic wrapper
apps/emprops-studio/pages/api/auth/me.tsJWT validation (reconstructs chunked cookies)
apps/emprops-studio/utils/cookies.tsChunked cookie read/write for large Dynamic JWTs
apps/emprops-studio/hooks/wallets.tsWallet CRUD hooks (Dynamic-specific)
apps/monitor/src/components/AuthProvider.tsxReference Privy implementation

Decision

Phase 1: Database - Unified user Table

Based on the universal user table ADR, but simplified for immediate needs:

prisma
model user {
  id              String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  privy_id        String?   @unique    // Privy DID (e.g., "did:privy:abc123")
  dynamic_id      String?   @unique    // Dynamic Labs user ID (for migration)
  wallet_address  String?              // Primary wallet address
  email           String?
  display_name    String?
  avatar_url      String?
  created_at      DateTime  @default(now())
  updated_at      DateTime  @updatedAt
  migrated_at     DateTime?            // When Dynamic->Privy migration completed

  miniapp_user    miniapp_user?

  @@index([wallet_address])
  @@index([email])
}

Add nullable FK to miniapp_user:

prisma
model miniapp_user {
  // ... existing fields ...
  user_id    String?  @unique @db.Uuid
  user       user?    @relation(fields: [user_id], references: [id])
}

Why UUID: Matches every other table in the schema. Existing user_id columns across 20+ tables are already @db.Uuid, so FK constraints can be added without column type changes. The privy_id column handles provider-specific lookups.

Migration: npx prisma migrate dev --name add_user_table - table starts empty, populated as users authenticate via Privy or get batch-imported.

Phase 2: EmProps Studio - Replace Dynamic with Privy

Dependencies:

Remove: @dynamic-labs/sdk-react-core, @dynamic-labs/ethereum
Add:    @privy-io/react-auth, @privy-io/server-auth

New Auth Provider (following monitor's AuthProvider.tsx pattern):

tsx
// apps/emprops-studio/components/PrivyAuthProvider.tsx
<PrivyProvider
  appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}
  config={{
    appearance: { theme: "dark" },
    loginMethods: ["wallet", "email"],
    embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" } }
  }}
>
  {children}
</PrivyProvider>

Auth Hook Abstraction:

tsx
// apps/emprops-studio/hooks/useAuth.ts
export function useAuth() {
  const { ready, authenticated, user, login, logout, getAccessToken } = usePrivy();
  return {
    ready,
    authenticated,
    user: user ? {
      id: user.id,
      email: user.email?.address,
      walletAddress: user.wallet?.address,
    } : null,
    login,
    logout,
    getAccessToken,
  };
}

API Route Updates:

  • pages/api/auth/me.ts: Replace Dynamic JWT reconstruction from chunked cookies with Privy token validation via @privy-io/server-auth. On successful auth, upsert user table with privy_id.
  • Remove writeChunkedCookies() / readChunkedCookies() - Privy handles its own session.

Environment Variables:

  • Add: NEXT_PUBLIC_PRIVY_APP_ID, PRIVY_APP_SECRET
  • Remove: NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID

Phase 3: User Migration (Dynamic -> Privy)

Strategy: Just-in-Time + Batch Import

Just-in-Time (Primary):

  1. Existing user logs into EmProps Studio with Privy (same wallet)
  2. Backend detects wallet address matches a known Dynamic user
  3. Creates user record linking both privy_id and dynamic_id
  4. Seamless transition

Batch Import (Backfill) via Privy API:

POST https://auth.privy.io/api/v1/users/import
Authorization: Basic base64(PRIVY_APP_ID:PRIVY_APP_SECRET)

{
  "users": [
    {
      "create_ethereum_wallet": false,
      "linked_accounts": [
        { "type": "wallet", "chain_type": "ethereum", "address": "0x..." }
      ]
    }
  ]
}
  • Max 20 users per request
  • Import by wallet address (primary identifier from Dynamic)
  • Returns Privy user IDs to store in user.privy_id

Migration Script (scripts/migrate-dynamic-to-privy.ts):

  1. Query all unique wallet addresses from existing user-related tables
  2. Batch import to Privy (20 at a time)
  3. Create user records with both dynamic_id and privy_id

Backfill Miniapp Users:

  1. Query miniapp_user records with Farcaster IDs
  2. Look up corresponding Privy accounts (by FID or wallet address)
  3. Create user records and link via miniapp_user.user_id

Once user table is established:

  1. user table becomes canonical identity
  2. social_link data modeled as Privy linked accounts
  3. Gradually migrate queries from social_link to user + Privy
  4. Eventually archive social_link

Note: This is a future phase - user coexists with social_link initially.


Implementation Order

  1. Database migration - Add user table + miniapp_user.user_id FK
  2. EmProps Studio Privy swap - Replace Dynamic with Privy provider
  3. Auth API routes - Privy JWT validation + user upsert logic
  4. JIT migration logic - Wallet-matching for returning Dynamic users
  5. Batch import script - Pre-create Privy accounts for existing users
  6. Backfill miniapp links - Connect miniapp_user records to user table
  7. Cleanup - Remove Dynamic SDK, chunked cookies, old env vars

Consequences

Positive

  • Single user identity across miniapp and studio
  • Privy handles wallet + email + Farcaster identities in one provider
  • Privy was acquired by Stripe - strong long-term viability
  • Monitor already uses Privy - consolidates on one auth provider

Negative

  • Migration effort for existing Dynamic users
  • Batch import has rate limits (20 users/request)
  • Temporary dual-auth period during rollout

Risks

  • Dynamic users who don't return won't get migrated (batch import mitigates)
  • Wallet address matching may miss users who used different wallets
  • Privy API changes could affect batch import

Verification

  1. npx prisma migrate dev - verify user table with autoincrement ID
  2. Fresh Privy login in EmProps Studio creates user record
  3. Returning Dynamic user with same wallet gets linked user record
  4. Batch import script creates Privy accounts on staging
  5. miniapp_user.user_id correctly links to user after backfill
  6. All studio features (collections, jobs, credits) work with new auth

Released under the MIT License.