Guest Users

Guest checkout and email-only user support

Overview

Zooly Auth supports a Shopify-style purchase flow where users can buy with just an email (no login). These guest users can later register and link their accounts to preserve purchase history.

Guest User Model

Two Identity States

The identities table supports two identity states:

  1. Cognito-linked users (authenticated)

    • user_id is Cognito sub
    • Normal auth flow
    • Email comes from Cognito ID token (source of truth)
  2. Email-only users (guest checkout)

    • user_id is generated by our system
    • guest_email is stored
    • No Cognito sub yet
    • Can make purchases without registration

Identity Structure

Email-only user (before linking):

{
  "user_id": "user-guest-123",
  "guest_email": "guest@example.com",
  "display_name": "Optional Name",
  "cognito_sub": null,
  "roles": [],
  "created_at": 1700000000,
  "updated_at": 1700000000
}

Linked user (after registration):

{
  "user_id": "user-guest-123",
  "guest_email": "guest@example.com",
  "display_name": "Optional Name",
  "cognito_sub": "cognito-sub-xyz",
  "roles": [],
  "created_at": 1700000000,
  "updated_at": 1700600000
}

Important: Email is NOT stored in DynamoDB. For Cognito-linked users, email comes from Cognito ID token (source of truth). The guest_email field is retained for historical reference.

Creating Guest Users

getOrCreateByEmail Function

Use getOrCreateByEmail to get or create a guest identity:

import { getOrCreateByEmail } from '@zooly/auth-db';

// In your checkout endpoint
export async function POST(request: Request) {
  const { email, name } = await request.json();
  
  // Get existing identity or create new guest identity
  const identity = await getOrCreateByEmail(email, name);
  
  // Use identity.user_id for purchase association
  const purchase = await createPurchase({
    userId: identity.user_id,
    // ... other purchase data
  });
  
  return Response.json({ 
    userId: identity.user_id,
    purchaseId: purchase.id 
  });
}

Behavior

  • If a user exists for the email (by guest_email), returns their user_id
  • If not, creates a new row in DynamoDB with guest_email and optional display_name
  • Returns the user_id for purchase association
  • These users do not have a Cognito user

Guest Checkout Flow

1. User enters email at checkout
2. Backend calls getOrCreateByEmail(email, name?)
3. System returns user_id (existing or new)
4. Purchase is associated with user_id
5. User completes purchase without registration

Linking on Registration

When a user registers with an email that was previously used for a guest checkout:

Automatic Linking

The system automatically links Cognito identities to existing guest identities:

  1. User registers with email guest@example.com
  2. System checks for existing identity with matching guest_email
  3. If found:
    • Links Cognito identity to existing user_id
    • Sets cognito_sub on existing identity
    • Preserves all purchase history and data
    • Email now comes from Cognito (source of truth)
    • guest_email is retained for historical reference

Implementation

The linking happens automatically in the signup/login flow:

// In @zooly/auth-srv signup/login functions
import { findIdentityByEmail, linkCognitoIdentity } from '@zooly/auth-db';

async function signUp(email: string, password: string) {
  // Create Cognito user
  const cognitoUser = await cognito.signUp(email, password);
  
  // Check for existing guest identity
  const existingIdentity = await findIdentityByEmail(email);
  
  if (existingIdentity && !existingIdentity.cognito_sub) {
    // Link Cognito identity to existing guest identity
    await linkCognitoIdentity(existingIdentity.user_id, cognitoUser.sub);
    
    // Return existing user_id (stable across linking)
    return existingIdentity.user_id;
  }
  
  // Create new identity for new user
  const identity = await upsertIdentity({
    user_id: cognitoUser.sub,
    cognito_sub: cognitoUser.sub,
    // ... other fields
  });
  
  return identity.user_id;
}

Benefits

This design enables:

  • Guest purchase now - Users can buy without registration
  • Optional registration later - Users can register anytime
  • Full purchase history visible after login - All purchases linked to same user_id
  • Seamless experience - No data loss when linking accounts

API Endpoint

GET /api/users/get-or-create

Endpoint for guest checkout:

// In auth app
export async function POST(request: Request) {
  const { email, name } = await request.json();
  
  const identity = await getOrCreateByEmail(email, name);
  
  return Response.json({
    userId: identity.user_id,
    email: identity.guest_email,
    name: identity.display_name,
  });
}

Usage:

// In your checkout app
const response = await fetch('https://auth.zooly.ai/api/users/get-or-create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ 
    email: 'guest@example.com',
    name: 'Guest User' // optional
  }),
});

const { userId } = await response.json();
// Use userId for purchase association

Purchase Association

Always associate purchases with user_id, regardless of login state:

// Purchase table
{
  purchase_id: "purchase-123",
  user_id: "user-guest-123", // Same user_id before and after linking
  // ... other purchase data
}

// After user registers and links:
// - user_id remains the same
// - All purchases remain associated
// - User sees full purchase history

User Profile After Linking

After linking, the user profile combines:

  • Email: From Cognito ID token (source of truth)
  • Display Name: From DynamoDB (can be updated)
  • Avatar: From DynamoDB (can be updated)
  • Roles: From DynamoDB (can be updated)
  • Purchase History: All purchases associated with user_id

Migration Considerations

If you're adding guest user support to an existing system:

  1. Existing users: Already have cognito_sub, no changes needed
  2. New guest users: Created with generated user_id and guest_email
  3. Linking: Happens automatically on first registration/login
  4. Data integrity: user_id remains stable across linking

Best Practices

  1. Always use user_id - Never use email as foreign key
  2. Preserve guest_email - Keep for historical reference after linking
  3. Email from Cognito - Always get email from JWT, not DynamoDB
  4. Automatic linking - Handle in signup/login flow, not manually
  5. Purchase association - Use user_id consistently across all purchases