Skip to content

ADR: Universal User Table ​

Date: 2026-03-02 Status: Accepted — Phase 1 deployed to staging 2026-03-04 Supersedes: 2025-12-30-universal-user-table.mdDecision Makers: Architecture Team, Founders


Executive Summary ​

The EmProps platform has no canonical User record in the database. Twenty+ tables store user_id as a bare UUID with no foreign key constraints and no corresponding row anywhere. All auth identity is derived from JWT tokens at request time, meaning users don't exist between requests — they're just claims.

This creates the "one user custodial model" problem: emergibles NFT collections default to SYSTEM_USER_ID (the zero UUID), meaning every collection created through emergibles is anonymous. There's no foundation for user-owned assets, cross-app identity, account linking, or the custody transfer needed to graduate custodial NFTs to real users.

This ADR establishes the user table as the canonical identity record and user_identity as the provider-mapping child table, and defines the migration strategy for connecting the existing codebase to this model.


Context ​

The Five Auth Providers In Use ​

ProviderUsed ByID FormatNotes
Dynamic Labsemprops-studioUUID stringLive, primary studio auth
PrivyEmergibles, Monitor, emprops-studio (migration)DID (did:privy:abc123)Primary auth for emergibles; being adopted across apps
Stack AuthMonitorStack User IDBeing phased out
WorldcoinEmergibles (legacy)Wallet address (0x...)SIWE via MiniKit — NextAuth code still exists but Privy is primary
FarcasterEmergibles (via Privy), miniappFID (integer as string)Privy supports as native linked account; miniapp uses SDK context

None of these produce the same ID format. The same human using two different providers has two completely different identities in the current system.

Update (2026-03-04): Emergibles has migrated from Worldcoin SIWE/NextAuth to Privy as its primary auth provider. Privy login methods: wallet, Twitter, Farcaster. Privy user IDs are DIDs like did:privy:clxyz.... NextAuth/Worldcoin code remains but is no longer the active auth path.

What Exists Today ​

No user table. The closest things are:

TableWhat It TracksProblem
miniapp_userFarcaster users onlySiloed, no relation to other providers
profileOrphaned display metadataNo FK to any identity
customerStripe billing identityNo FK to users
social_linkCustodial social ownershipMaps collections to social handles, not users

20+ tables with user_id:job, project, project_history, flat_file, chat_message, api_key, user_api_key, role, credits_balance, credits_history, miniapp_claim_activity, miniapp_generation, miniapp_payment, emerge_reward_claim_history, pfp_revert_schedule, redis_step_archive, redis_workflow_archive, and more — all bare UUIDs, no FK constraints, no guarantee the value refers to anything real.

The Custodial NFT Problem ​

When emergibles generates an NFT collection, it sends this to the EmProps API:

typescript
{
  concept: "...",
  social_org: 'farcaster',
  social_identifier: body.social_identifier,  // user's Farcaster handle
  save: true,
  project_id: process.env.NFT_PROJECT_ID,   // shared platform project
  // user_id: not passed → defaults to SYSTEM_USER_ID
}

The collection ends up owned by SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000". Every collection from every emergibles user is attributed to the same phantom account. There's no path to:

  • Show a user their own collections
  • Transfer ownership when a Worldcoin user is also the Farcaster user
  • Enforce any access control on collection mutations

Decision ​

Schema: Two-Table Identity Model ​

user (canonical internal identity)
 └── user_identity (one row per provider per user)

Why child table, not wider user table: Each provider has different credential shapes. Adding columns to user for each provider creates a sparse table where 4 out of 5 provider columns are always null. A child table is normalized, lets us add providers without schema changes, and makes account linking (same user, multiple providers) a first-class concept.

user Table ​

prisma
model user {
  id            String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  email         String?   @unique @db.VarChar(255)
  display_name  String?   @db.VarChar(255)
  avatar_url    String?   @db.VarChar(500)
  created_at    DateTime  @default(now()) @db.Timestamptz
  updated_at    DateTime  @updatedAt @db.Timestamptz
  last_login_at DateTime? @db.Timestamptz
  is_active     Boolean   @default(true)
  is_blocked    Boolean   @default(false)

  identities    user_identity[]
  miniapp_user  miniapp_user?         // Farcaster extension, eventually deprecated
  profile       profile?              // Display preferences

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

user_identity Table ​

prisma
model user_identity {
  id               String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  user_id          String    @db.Uuid
  user             user      @relation(fields: [user_id], references: [id], onDelete: Cascade)

  provider         String    @db.VarChar(30)   // See provider values below
  provider_user_id String    @db.VarChar(500)  // External ID (DID, UUID, FID, 0x address)
  provider_email   String?   @db.VarChar(255)
  provider_metadata Json?    @db.JsonB          // Full JWT claims or profile data

  linked_at        DateTime  @default(now()) @db.Timestamptz
  last_used_at     DateTime? @db.Timestamptz

  @@unique([provider, provider_user_id])
  @@index([user_id])
  @@index([provider])
}

Valid provider values:

ValueSourceID Example
dynamicDynamic Labs JWT sub9e69f... (UUID)
privyPrivy JWT subdid:privy:clxyz...
stackStack Auth JWT subStack User ID
worldcoinWorldcoin SIWE wallet address0xABC...
farcasterFarcaster FID from SDK"12345"

Account Linking Strategy ​

When a user authenticates for the first time:

  1. Look up user_identity by (provider, provider_user_id)
  2. If found → return existing user.id
  3. If not found AND email exists → look for existing user with same email → link the new provider to that user
  4. If not found AND no email match → create new user + user_identity

This handles the common case: a creator who uses emprops-studio (Dynamic/Privy) and also uses emergibles (Worldcoin) with the same email will end up with one user record linking both provider accounts.

miniapp_user Lifecycle ​

miniapp_user is the current Farcaster-specific user table. It contains fields like canCreateWorkflows, canGenerate, isBlocked that are emergibles/miniapp-specific permissions.

Decision:

  • Phase 1: Add optional user_id FK to miniapp_user. Farcaster users get a user record, miniapp_user.user_id points to it.
  • Future (separate ADR): Once all Farcaster identity lives in user_identity, audit the Farcaster-specific permission fields — migrate what's needed to user (e.g., is_blocked) and deprecate miniapp_user.

This avoids a big-bang migration of a live table.


What This Unlocks ​

CapabilityBeforeAfter
NFT collections owned by real usersAll → SYSTEM_USER_IDEach collection → real user.id
Show user their collectionsImpossibleWHERE user_id = internalUserId
Same person on two platforms = one identityTwo different IDsOne user, two user_identity rows
Custody transferCollections stuck at social_linkTransfer from social_link to user.id
FK integrity20+ orphaned UUID columnsEnforceable after backfill
Block a user platform-widePer-app onlyuser.is_blocked = true

Migration Strategy ​

Staged Deployment — Emergibles First ​

Updated 2026-03-04: Restructured from the original 6-phase plan into independently-deployable stages. Emergibles adopts the user table first, then miniapp, then remaining apps. Each stage is independently deployable and non-breaking.

StageWhatDeploy TargetBreaking?
0Create user + user_identity tablesMigration onlyNo
1New POST /api/auth/resolve endpoint + getOrCreateUseremprops-apiNo
2Middleware resolves provider_id → internal UUIDemprops-apiNo (fallback)
3Wire emergibles to pass Privy token + user_idemergiblesNo
4Backfill miniapp_user + wire miniappminiappNo
5+Remaining apps (Monitor, Studio)Per-appNo
NAdd FK constraints to existing tablesMaintenance windowCareful deploy

Key decision: Separate /api/auth/resolve endpoint — The existing /api/auth/session endpoint is left unchanged. A new /api/auth/resolve endpoint handles user creation/lookup. This is additive and avoids modifying a live endpoint used by multiple apps.

Key decision: Privy auth for emergibles — Emergibles uses Privy (not Worldcoin SIWE) for client auth. The emergibles Next.js API routes pass the Privy access token to /api/auth/resolve, which verifies the JWT and returns an internal UUID.

Soft Migration Design (Stage 2) ​

The middleware currently sets:

req.headers["user_id"] = providerUserId  // e.g., did:privy:abc123

After Stage 2, the middleware resolves the internal UUID first and falls back:

req.headers["user_id"] = internalUuid ?? providerUserId  // internal preferred
req.headers["provider_user_id"] = providerUserId          // always available
req.headers["auth_provider"] = provider

Existing code that uses req.headers["user_id"] continues to work — it just gets the internal UUID for users who have been resolved via /api/auth/resolve, and the provider ID for everyone else. Over time, as users authenticate, all active users will have internal UUIDs.


What This Does NOT Change ​

  • Auth provider decisions — we keep using Dynamic, Privy, Stack, Worldcoin, Farcaster as-is
  • The custodial model (social_link, is_custodial) for collections that were already created anonymously — those remain; custody transfer is a separate feature
  • The JWT verification system (unified-jwt.ts) — it stays, we just add a DB lookup step after it

Consequences ​

Positive ​

  • Single source of truth for user identity across all five providers and all three apps
  • Referential integrity achievable after backfill (Phase 6)
  • Account linking built in by design, not bolted on later
  • Emergibles NFTs are attributable — escapes the SYSTEM_USER_ID trap
  • Future-proof — add a sixth auth provider by adding a new provider value, no schema change
  • Audit foundation — created_at, last_login_at, linked_at give visibility into user behavior

Negative ​

  • Additional DB lookup on every authenticated request (Phase 3) — single indexed unique lookup, negligible in practice; can be cached in Redis if needed
  • Backfill ambiguity — historical user_id values in old records may be Dynamic IDs, Privy DIDs, or Stack IDs — we can't always determine which provider created them without provider context at creation time
  • Duplicate user risk — the same person on two providers without a shared email cannot be auto-linked; they get two user records until they self-merge (see User Merge in implementation guide)

Real Complexity Areas ​

These are the actual difficult parts — not duration but downstream effect:

AreaWhy It's Complex
Middleware change (Stage 2)Every authenticated request in every app flows through this. A bug here breaks all auth. Must be deployed with fallback and monitored closely.
Emergibles user resolution (Stage 3)Emergibles Next.js API routes call /api/auth/resolve before forwarding to NFT endpoints. If emprops-api is down, resolution fails — but graceful degradation means collections still work (just without user attribution).
Historical user_id backfill (Stage N)20+ tables have orphaned user_id values. Some are Dynamic UUIDs, some Privy DIDs, some unknown. Full FK enforcement requires knowing the provider context for every historical record — which isn't always available. Stage N may never be 100% complete.
User mergeTwo user records confirmed to be the same person must be collapsed atomically — all owned records, all user_identity rows reassigned from secondary to primary, secondary deleted. This is a core platform operation, not an edge case. Full spec in implementation guide.

Risks ​

RiskMitigation
Auth middleware regressionSoft fallback to provider ID if user lookup fails; never throw on lookup error
Emergibles NFT creation without user_idGraceful degradation: if /api/auth/resolve fails, collection still created with SYSTEM_USER_ID fallback. No login failure.
FK constraints fail on Stage NStage N is explicitly deferrable — the system works without it; add constraints only after full audit
Duplicate user recordsResolved via user merge — atomic transaction that collapses two records into one; user-initiated via profile settings

Alternatives Considered ​

1. Widen the user table with one column per provider ​

Rejected. Five providers × different metadata shapes = sparse table with 20+ nullable columns. Adding a sixth provider requires a schema migration. The child table is the correct normalization.

2. Use Privy as the universal identity provider ​

Ideal long-term, not feasible now. Privy can federate Worldcoin, Farcaster, wallet sign-in, etc. under one DID. But emergibles uses MiniKit (Worldcoin's own wallet), and Farcaster identity comes from the miniapp SDK context — not Privy. Migrating to a Privy-as-federation model requires product changes to the auth flows in emergibles and miniapp. This ADR doesn't require that — it's compatible with a future Privy migration.

3. Keep provider IDs everywhere, just add FK validation ​

Rejected. Would require one FK constraint per provider per table, or a polymorphic FK — both are worse than a single canonical UUID. Also doesn't solve account linking.


Released under the MIT License.