Skip to content

Opt-In Token

This example demonstrates how DCB can be leveraged to replace a Read Model when implementing a Double opt-in

Challenge

A Double opt-in process that requires users to confirm their email address before an account is created

Traditional approaches

  • Stateless: Store required data and expiration timestamp in an encrypted/signed token

    This works, but it can lead to very long tokens

  • Persisted token: The server generates and stores a unique token, tied to the specified email address. When the email address is confirmed, the token is verified and invalidated (e.g., deleted).

    This method allows the tokens to be short but adds infrastructure overhead and complexity, and may result in stale or unused tokens accumulating over time

DCB approach

With DCB, a short token (i.e. OTP) can be generated on the server and stored with the data of the initial Event (SignUpInitiated).

With that, a dedicated Decision Model can be created that verifies the token. The token is invalidated as soon as the sign up was finalized (SignUpConfirmed Event)

Feature 1: Simple One-Time Password (OTP)

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function SignUpInitiated({ emailAddress, otp, name }) {
  return {
    type: "SignUpInitiated",
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

function SignUpConfirmed({ emailAddress, otp, name }) {
  return {
    type: "SignUpConfirmed",
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

// projections for decision models:

function PendingSignUpProjection(emailAddress, otp) {
  return createProjection({
    initialState: null,
    handlers: {
      SignUpInitiated: (state, event) => ({data: event.data, otpUsed: false}),
      SignUpConfirmed: (state, event) => ({...state, otpUsed: true}),
    },
    tagFilter: [`email:${emailAddress}`, `otp:${otp}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  confirmSignUp(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      pendingSignUp: PendingSignUpProjection(command.emailAddress, command.otp),
    })
    if (!state.pendingSignUp) {
      throw new Error("No pending sign-up for this OTP / email address")
    }
    if (state.pendingSignUp.otpUsed) {
      throw new Error("OTP was already used")
    }
    this.eventStore.append(
      SignUpConfirmed({
        emailAddress: command.emailAddress,
        otp: command.otp,
        name: state.pendingSignUp.data.name,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Confirm SignUp for non-existing OTP",
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"000000"},
      }
    },
    then: {
      expectedError: "No pending sign-up for this OTP \/ email address",
    }
  }, 
  {
    description: "Confirm SignUp for OTP assigned to different email address",
    given: {
      events: [
        SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"111111","name":"John Doe"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"jane.doe@example.com","otp":"111111"},
      }
    },
    then: {
      expectedError: "No pending sign-up for this OTP \/ email address",
    }
  }, 
  {
    description: "Confirm SignUp for already used OTP",
    given: {
      events: [
        SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"222222","name":"John Doe"}),
        SignUpConfirmed({"emailAddress":"john.doe@example.com","otp":"222222","name":"John Doe"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"222222"},
      }
    },
    then: {
      expectedError: "OTP was already used",
    }
  }, 
  {
    description: "Confirm SignUp for valid OTP",
    given: {
      events: [
        SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"444444","name":"John Doe"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"444444"},
      }
    },
    then: {
      expectedEvent: SignUpConfirmed({"emailAddress":"john.doe@example.com","otp":"444444","name":"John Doe"}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function SignUpInitiated({
  emailAddress,
  otp,
  name,
} : {
  emailAddress: string,
  otp: string,
  name: string,
}) {
  return {
    type: "SignUpInitiated" as const,
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

function SignUpConfirmed({
  emailAddress,
  otp,
  name,
} : {
  emailAddress: string,
  otp: string,
  name: string,
}) {
  return {
    type: "SignUpConfirmed" as const,
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

type EventTypes = ReturnType<
  | typeof SignUpInitiated,
  | typeof SignUpConfirmed,
>

// projections for decision models:

function PendingSignUpProjection(emailAddress: string, otp: string) {
  return createProjection<EventTypes, { data: { name: string }; otpUsed: boolean }>({
    initialState: null,
    handlers: {
      SignUpInitiated: (state, event) => ({data: event.data, otpUsed: false}),
      SignUpConfirmed: (state, event) => ({...state, otpUsed: true}),
    },
    tagFilter: [`email:${emailAddress}`, `otp:${otp}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  confirmSignUp(command: { emailAddress: string; otp: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      pendingSignUp: PendingSignUpProjection(command.emailAddress, command.otp),
    })
    if (!state.pendingSignUp) {
      throw new Error("No pending sign-up for this OTP / email address")
    }
    if (state.pendingSignUp.otpUsed) {
      throw new Error("OTP was already used")
    }
    this.eventStore.append(
      SignUpConfirmed({
        emailAddress: command.emailAddress,
        otp: command.otp,
        name: state.pendingSignUp.data.name,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Feature 2: Expiring OTP

A requirement might be to expire tokens after a given time (for example: 60 minutes). The example can be easily adjusted to implement that feature:

Note

The minutesAgo property of the Event metadata is a simplification. Typically, a timestamp representing the Event's recording time is stored within the Event's payload or metadata. This timestamp can be compared to the current date to determine the Event's age in the decision model.

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function SignUpInitiated({ emailAddress, otp, name }) {
  return {
    type: "SignUpInitiated",
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

function SignUpConfirmed({ emailAddress, otp, name }) {
  return {
    type: "SignUpConfirmed",
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

// projections for decision models:

function PendingSignUpProjection(emailAddress, otp) {
  return createProjection({
    initialState: null,
    handlers: {
      SignUpInitiated: (state, event) => ({data: event.data, otpUsed: false, otpExpired: event.metadata?.minutesAgo > 60}),
      SignUpConfirmed: (state, event) => ({...state, otpUsed: true}),
    },
    tagFilter: [`email:${emailAddress}`, `otp:${otp}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  confirmSignUp(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      pendingSignUp: PendingSignUpProjection(command.emailAddress, command.otp),
    })
    if (!state.pendingSignUp) {
      throw new Error("No pending sign-up for this OTP / email address")
    }
    if (state.pendingSignUp.otpUsed) {
      throw new Error("OTP was already used")
    }
    if (state.pendingSignUp.otpExpired) {
      throw new Error("OTP expired")
    }
    this.eventStore.append(
      SignUpConfirmed({
        emailAddress: command.emailAddress,
        otp: command.otp,
        name: state.pendingSignUp.data.name,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Confirm SignUp for non-existing OTP",
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"000000"},
      }
    },
    then: {
      expectedError: "No pending sign-up for this OTP \/ email address",
    }
  }, 
  {
    description: "Confirm SignUp for OTP assigned to different email address",
    given: {
      events: [
        SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"111111","name":"John Doe"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"jane.doe@example.com","otp":"111111"},
      }
    },
    then: {
      expectedError: "No pending sign-up for this OTP \/ email address",
    }
  }, 
  {
    description: "Confirm SignUp for already used OTP",
    given: {
      events: [
        SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"222222","name":"John Doe"}),
        SignUpConfirmed({"emailAddress":"john.doe@example.com","otp":"222222","name":"John Doe"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"222222"},
      }
    },
    then: {
      expectedError: "OTP was already used",
    }
  }, 
  {
    description: "Confirm SignUp for valid OTP",
    given: {
      events: [
        SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"444444","name":"John Doe"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"444444"},
      }
    },
    then: {
      expectedEvent: SignUpConfirmed({"emailAddress":"john.doe@example.com","otp":"444444","name":"John Doe"}),
    }
  }, 
  {
    description: "Confirm SignUp for expired OTP",
    given: {
      events: [
        addEventMetadata(SignUpInitiated({"emailAddress":"john.doe@example.com","otp":"333333","name":"John Doe"}), {"minutesAgo":"61"}),
      ],
    },
    when: {
      command: {
        type: "confirmSignUp",
        data: {"emailAddress":"john.doe@example.com","otp":"000000"},
      }
    },
    then: {
      expectedError: "No pending sign-up for this OTP \/ email address",
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function SignUpInitiated({
  emailAddress,
  otp,
  name,
} : {
  emailAddress: string,
  otp: string,
  name: string,
}) {
  return {
    type: "SignUpInitiated" as const,
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

function SignUpConfirmed({
  emailAddress,
  otp,
  name,
} : {
  emailAddress: string,
  otp: string,
  name: string,
}) {
  return {
    type: "SignUpConfirmed" as const,
    data: { emailAddress, otp, name },
    tags: [`email:${emailAddress}`, `otp:${otp}`],
  }
}

type EventTypes = ReturnType<
  | typeof SignUpInitiated,
  | typeof SignUpConfirmed,
>

// projections for decision models:

function PendingSignUpProjection(emailAddress: string, otp: string) {
  return createProjection<EventTypes, { data: { name: string }; otpUsed: boolean; otpExpired: boolean }>({
    initialState: null,
    handlers: {
      SignUpInitiated: (state, event) => ({data: event.data, otpUsed: false, otpExpired: event.metadata?.minutesAgo > 60}),
      SignUpConfirmed: (state, event) => ({...state, otpUsed: true}),
    },
    tagFilter: [`email:${emailAddress}`, `otp:${otp}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  confirmSignUp(command: { emailAddress: string; otp: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      pendingSignUp: PendingSignUpProjection(command.emailAddress, command.otp),
    })
    if (!state.pendingSignUp) {
      throw new Error("No pending sign-up for this OTP / email address")
    }
    if (state.pendingSignUp.otpUsed) {
      throw new Error("OTP was already used")
    }
    if (state.pendingSignUp.otpExpired) {
      throw new Error("OTP expired")
    }
    this.eventStore.append(
      SignUpConfirmed({
        emailAddress: command.emailAddress,
        otp: command.otp,
        name: state.pendingSignUp.data.name,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Conclusion

This example demonstrates, how DCB can be used to implement a simple double opt-in functionality without the need for additional Read Models or Cryptography