From 2e137cf57a7d22fb9af7e599b4eaeaef0d6e3796 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 14:10:39 +0545 Subject: [PATCH 01/19] feat: create a passwordless recipe --- .../recipes/initPasswordlessRecipe.ts | 19 ++++++++++++ packages/user/src/supertokens/types/index.ts | 5 +++ .../supertokens/types/passwordlessRecipe.ts | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts create mode 100644 packages/user/src/supertokens/types/passwordlessRecipe.ts diff --git a/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts new file mode 100644 index 000000000..f65c109d6 --- /dev/null +++ b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts @@ -0,0 +1,19 @@ +import { FastifyInstance } from "fastify"; +import Passwordless from "supertokens-node/recipe/passwordless"; + +import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig"; + +import type { SupertokensRecipes } from "../types"; + +const init = (fastify: FastifyInstance) => { + const passwordless: SupertokensRecipes["passwordless"] = + fastify.config.user.supertokens.recipes?.passwordless; + + if (typeof passwordless === "function") { + return Passwordless.init(passwordless(fastify)); + } + + return Passwordless.init(getPasswordlessRecipeConfig(fastify)); +}; + +export default init; diff --git a/packages/user/src/supertokens/types/index.ts b/packages/user/src/supertokens/types/index.ts index b22ad8302..caa988057 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -6,10 +6,12 @@ import { } from "supertokens-node/recipe/thirdpartyemailpassword"; import type { EmailVerificationRecipe } from "./emailVerificationRecipe"; +import type { PasswordlessRecipe } from "./passwordlessRecipe"; import type { SessionRecipe } from "./sessionRecipe"; import type { ThirdPartyEmailPasswordRecipe } from "./thirdPartyEmailPasswordRecipe"; import type { FastifyInstance } from "fastify"; import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; +import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; @@ -19,6 +21,9 @@ interface SupertokensRecipes { emailVerification?: | EmailVerificationRecipe | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig); + passwordless?: + | PasswordlessRecipe + | ((fastify: FastifyInstance) => PasswordlessRecipeConfig); session?: SessionRecipe | ((fastify: FastifyInstance) => SessionRecipeConfig); userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; thirdPartyEmailPassword?: diff --git a/packages/user/src/supertokens/types/passwordlessRecipe.ts b/packages/user/src/supertokens/types/passwordlessRecipe.ts new file mode 100644 index 000000000..13c202106 --- /dev/null +++ b/packages/user/src/supertokens/types/passwordlessRecipe.ts @@ -0,0 +1,31 @@ +import { FastifyInstance } from "fastify"; + +import type { + APIInterface, + RecipeInterface, +} from "supertokens-node/recipe/passwordless/types"; + +type APIInterfaceWrapper = { + [key in keyof APIInterface]?: ( + originalImplementation: APIInterface, + fastify: FastifyInstance, + ) => APIInterface[key]; +}; + +type RecipeInterfaceWrapper = { + [key in keyof RecipeInterface]?: ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, + ) => RecipeInterface[key]; +}; + +interface PasswordlessRecipe { + contactMethod?: "EMAIL" | "PHONE" | "EMAIL_OR_PHONE"; + flowType?: "USER_INPUT_CODE"; + override?: { + apis?: APIInterfaceWrapper; + functions?: RecipeInterfaceWrapper; + }; +} + +export type { APIInterfaceWrapper, RecipeInterfaceWrapper, PasswordlessRecipe }; From 4584f1dd3d679af50f215e7dd7fad8210113f7fb Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 14:11:48 +0545 Subject: [PATCH 02/19] feat: create passwordless recipe config --- .../config/passwordlessRecipeConfig.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts new file mode 100644 index 000000000..b322a34e1 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -0,0 +1,82 @@ +import { FastifyInstance } from "fastify"; + +import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; + +import type { + APIInterface, + RecipeInterface, + TypeInput as PasswordlessRecipeConfig, +} from "supertokens-node/recipe/passwordless/types"; + +const getPasswordlessRecipeConfig = ( + fastify: FastifyInstance, +): PasswordlessRecipeConfig => { + const { config } = fastify; + + let passwordless: PasswordlessRecipe = {}; + + if (typeof config.user.supertokens.recipes?.passwordless === "object") { + passwordless = config.user.supertokens.recipes.passwordless; + } + + return { + contactMethod: passwordless?.contactMethod || "EMAIL", + flowType: passwordless?.flowType || "USER_INPUT_CODE", + override: { + apis: (originalImplementation) => { + const apiInterface: Partial = {}; + + if (passwordless.override?.apis) { + const apis = passwordless.override.apis; + + let api: keyof APIInterface; + + for (api in apis) { + const apiWrapper = apis[api]; + + if (apiWrapper) { + apiInterface[api] = apiWrapper( + originalImplementation, + fastify, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + } + } + } + + return { + ...originalImplementation, + ...apiInterface, + }; + }, + functions: (originalImplementation) => { + const recipeInterface: Partial = {}; + + if (passwordless.override?.functions) { + const recipes = passwordless.override.functions; + + let recipe: keyof RecipeInterface; + + for (recipe in recipes) { + const recipeWrapper = recipes[recipe]; + + if (recipeWrapper) { + recipeInterface[recipe] = recipeWrapper( + originalImplementation, + fastify, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + } + } + } + + return { + ...originalImplementation, + ...recipeInterface, + }; + }, + }, + }; +}; + +export default getPasswordlessRecipeConfig; From 0ef6d3600c262334d2a2f94ff8ad6a56b026eb15 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 14:18:00 +0545 Subject: [PATCH 03/19] feat: use passwordless recipe in supertokens auth --- packages/user/src/supertokens/recipes/index.ts | 5 +++++ packages/user/src/types/config.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 46d702268..63d7e52f0 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -1,4 +1,5 @@ import initEmailVerificationRecipe from "./initEmailVerificationRecipe"; +import initPasswordlessRecipe from "./initPasswordlessRecipe"; import initSessionRecipe from "./initSessionRecipe"; import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe"; import initUserRolesRecipe from "./initUserRolesRecipe"; @@ -17,6 +18,10 @@ const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { recipeList.push(initEmailVerificationRecipe(fastify)); } + if (fastify.config.user.features?.signUp?.passwordless) { + recipeList.push(initPasswordlessRecipe(fastify)); + } + return recipeList; }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d98cd658f..ffdd9231d 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -45,6 +45,10 @@ interface UserConfig { * @default false */ emailVerification?: boolean; + /** + * @default false + */ + passwordless?: boolean; }; updateEmail?: { enabled?: boolean; From fd7af3278febfd65bf183fdb1a92e64ecc624b77 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 14:28:22 +0545 Subject: [PATCH 04/19] feat: update implementation usage of passwordless auth --- .../config/passwordlessRecipeConfig.ts | 60 +------------------ .../user/src/supertokens/recipes/index.ts | 5 +- packages/user/src/types/config.ts | 4 -- 3 files changed, 2 insertions(+), 67 deletions(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index b322a34e1..118617faf 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -2,11 +2,7 @@ import { FastifyInstance } from "fastify"; import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; -import type { - APIInterface, - RecipeInterface, - TypeInput as PasswordlessRecipeConfig, -} from "supertokens-node/recipe/passwordless/types"; +import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, @@ -22,60 +18,6 @@ const getPasswordlessRecipeConfig = ( return { contactMethod: passwordless?.contactMethod || "EMAIL", flowType: passwordless?.flowType || "USER_INPUT_CODE", - override: { - apis: (originalImplementation) => { - const apiInterface: Partial = {}; - - if (passwordless.override?.apis) { - const apis = passwordless.override.apis; - - let api: keyof APIInterface; - - for (api in apis) { - const apiWrapper = apis[api]; - - if (apiWrapper) { - apiInterface[api] = apiWrapper( - originalImplementation, - fastify, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) as any; - } - } - } - - return { - ...originalImplementation, - ...apiInterface, - }; - }, - functions: (originalImplementation) => { - const recipeInterface: Partial = {}; - - if (passwordless.override?.functions) { - const recipes = passwordless.override.functions; - - let recipe: keyof RecipeInterface; - - for (recipe in recipes) { - const recipeWrapper = recipes[recipe]; - - if (recipeWrapper) { - recipeInterface[recipe] = recipeWrapper( - originalImplementation, - fastify, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) as any; - } - } - } - - return { - ...originalImplementation, - ...recipeInterface, - }; - }, - }, }; }; diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 63d7e52f0..ffe3de880 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -9,6 +9,7 @@ import type { RecipeListFunction } from "supertokens-node/types"; const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { const recipeList = [ + initPasswordlessRecipe(fastify), initSessionRecipe(fastify), initThirdPartyEmailPassword(fastify), initUserRolesRecipe(fastify), @@ -18,10 +19,6 @@ const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { recipeList.push(initEmailVerificationRecipe(fastify)); } - if (fastify.config.user.features?.signUp?.passwordless) { - recipeList.push(initPasswordlessRecipe(fastify)); - } - return recipeList; }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index ffdd9231d..d98cd658f 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -45,10 +45,6 @@ interface UserConfig { * @default false */ emailVerification?: boolean; - /** - * @default false - */ - passwordless?: boolean; }; updateEmail?: { enabled?: boolean; From 1df4a191784936c43c54e0b15af2eb323bb6f5c4 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 16:27:04 +0545 Subject: [PATCH 05/19] feat: add twilio types in config --- packages/config/src/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 59b0eea14..296336d41 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -57,6 +57,19 @@ interface ApiConfig { rest: { enabled: boolean; }; + twilio: + | { + accountSid: string; + authToken: string; + from: string; + opts?: Record; + } + | { + accountSid: string; + authToken: string; + messagingServiceSid: string; + opts?: Record; + }; version: string; } From dc2061451e0a6e197bb6177303740ac8c227940d Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 16:27:56 +0545 Subject: [PATCH 06/19] feat: use twilio service for sms delivery --- .../config/passwordlessRecipeConfig.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 118617faf..56660fdc7 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -1,7 +1,9 @@ import { FastifyInstance } from "fastify"; +import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery"; import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; const getPasswordlessRecipeConfig = ( @@ -15,9 +17,35 @@ const getPasswordlessRecipeConfig = ( passwordless = config.user.supertokens.recipes.passwordless; } + if (!("messagingServiceSid" in config.twilio) && !("from" in config.twilio)) { + throw new Error( + "Twilio config requires either messagingServiceSid or from", + ); + } + + const twilioSettings: TwilioServiceConfig = + "messagingServiceSid" in config.twilio + ? { + opts: config.twilio.opts, + accountSid: config.twilio.accountSid, + authToken: config.twilio.authToken, + messagingServiceSid: config.twilio.messagingServiceSid, + } + : { + opts: config.twilio.opts, + accountSid: config.twilio.accountSid, + authToken: config.twilio.authToken, + from: config.twilio.from, + }; + return { - contactMethod: passwordless?.contactMethod || "EMAIL", + contactMethod: passwordless?.contactMethod || "PHONE", flowType: passwordless?.flowType || "USER_INPUT_CODE", + smsDelivery: { + service: new TwilioService({ + twilioSettings, + }), + }, }; }; From c2ad862292a5d705857fd8b37b5615e6f27aa1c8 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 17:50:53 +0545 Subject: [PATCH 07/19] feat: implement overriding the sms text --- .../recipes/config/passwordlessRecipeConfig.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 56660fdc7..c9ef111e5 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -44,6 +44,20 @@ const getPasswordlessRecipeConfig = ( smsDelivery: { service: new TwilioService({ twilioSettings, + override: (originalImplementation) => { + return { + ...originalImplementation, + getContent: async (input) => { + return { + body: `Your verification code is: ${input.userInputCode}.`, + toPhoneNumber: input.phoneNumber, + }; + }, + sendRawSms: async (input) => { + await originalImplementation.sendRawSms(input); + }, + }; + }, }), }, }; From 9c3f100eb28c0c373d5a41d30a9bb6e1432af2e3 Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 31 Mar 2026 15:28:09 +0545 Subject: [PATCH 08/19] feat: add consumeCode function for passwordless user creation and override API --- .../config/passwordless/consumeCode.ts | 73 +++++++++++++++++++ .../config/passwordlessRecipeConfig.ts | 63 +++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts new file mode 100644 index 000000000..ff82d9053 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -0,0 +1,73 @@ +import { deleteUser, getRequestFromUserContext } from "supertokens-node"; + +import { UserCreateInput } from "src/types"; + +import getUserService from "../../../../lib/getUserService"; + +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types"; + +const consumeCode = ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, +): RecipeInterface["consumeCode"] => { + return async (input) => { + const originalResponse = await originalImplementation.consumeCode(input); + + if (originalResponse.status !== "OK" || !originalResponse.createdNewUser) { + return originalResponse; + } + + const request = getRequestFromUserContext(input.userContext)?.original as + | FastifyRequest + | undefined; + + const userService = getUserService( + request?.config || fastify.config, + request?.slonik || fastify.slonik, + request?.dbSchema, + ); + + const phoneNumber = originalResponse.user.phoneNumber; + + const emailHost = + fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; + + const email = phoneNumber + ? `${phoneNumber}@${emailHost}` + : originalResponse.user.email; + + if (!email || !phoneNumber) { + await deleteUser(originalResponse.user.id); + + throw new Error("Passwordless user missing phoneNumber or email"); + } + + try { + const user = await userService.create({ + id: originalResponse.user.id, + email, + phoneNumber, + } as UserCreateInput); + + if (!user) { + throw new Error("User not found"); + } + } catch (error) { + await deleteUser(originalResponse.user.id); + + throw error; + } + + return { + ...originalResponse, + user: { + ...originalResponse.user, + email, + phoneNumber, + }, + }; + }; +}; + +export default consumeCode; diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index c9ef111e5..c7828b875 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -3,8 +3,14 @@ import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery" import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; +import consumeCode from "./passwordless/consumeCode"; + import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; -import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; +import type { + APIInterface, + RecipeInterface, + TypeInput as PasswordlessRecipeConfig, +} from "supertokens-node/recipe/passwordless/types"; const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, @@ -41,6 +47,61 @@ const getPasswordlessRecipeConfig = ( return { contactMethod: passwordless?.contactMethod || "PHONE", flowType: passwordless?.flowType || "USER_INPUT_CODE", + override: { + apis: (originalImplementation) => { + const apiInterface: Partial = {}; + + if (passwordless.override?.apis) { + const apis = passwordless.override.apis; + + let api: keyof APIInterface; + + for (api in apis) { + const apiWrapper = apis[api]; + + if (apiWrapper) { + apiInterface[api] = apiWrapper( + originalImplementation, + fastify, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + } + } + } + + return { + ...originalImplementation, + ...apiInterface, + }; + }, + functions: (originalImplementation) => { + const recipeInterface: Partial = {}; + + if (passwordless.override?.functions) { + const recipes = passwordless.override.functions; + + let recipe: keyof RecipeInterface; + + for (recipe in recipes) { + const recipeWrapper = recipes[recipe]; + + if (recipeWrapper) { + recipeInterface[recipe] = recipeWrapper( + originalImplementation, + fastify, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + } + } + } + + return { + ...originalImplementation, + consumeCode: consumeCode(originalImplementation, fastify), + ...recipeInterface, + }; + }, + }, smsDelivery: { service: new TwilioService({ twilioSettings, From cdebc0e2057e0319c0a4400eafb935f86d212588 Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 31 Mar 2026 15:40:44 +0545 Subject: [PATCH 09/19] feat: make twilio configuration optional and add error handling for missing config --- packages/config/src/types.ts | 2 +- .../supertokens/recipes/config/passwordlessRecipeConfig.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 296336d41..893d63249 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -57,7 +57,7 @@ interface ApiConfig { rest: { enabled: boolean; }; - twilio: + twilio?: | { accountSid: string; authToken: string; diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index c7828b875..99d4f68e5 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -23,6 +23,12 @@ const getPasswordlessRecipeConfig = ( passwordless = config.user.supertokens.recipes.passwordless; } + if (!config.twilio) { + throw new Error( + "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", + ); + } + if (!("messagingServiceSid" in config.twilio) && !("from" in config.twilio)) { throw new Error( "Twilio config requires either messagingServiceSid or from", From 6fd9a63c6248de33d945eab530c6ac8af07559a5 Mon Sep 17 00:00:00 2001 From: anvesh Date: Wed, 1 Apr 2026 12:17:57 +0545 Subject: [PATCH 10/19] feat: add fallbackEmailDomain option to UserConfig interface --- packages/user/src/types/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d98cd658f..36a94f28e 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -23,6 +23,7 @@ interface UserConfig { resetPassword?: EmailOptions; resetPasswordNotification?: EmailOptions; }; + fallbackEmailDomain?: string; features?: { profileValidation?: { /** From 087ec9957df3a8089f37cad1f35ac1d5500d2e90 Mon Sep 17 00:00:00 2001 From: anvesh Date: Wed, 1 Apr 2026 12:45:21 +0545 Subject: [PATCH 11/19] feat: implement consumeCodePOST function and integrate with passwordless recipe config --- .../config/passwordless/consumeCode.ts | 81 +++++++++++++++---- .../config/passwordless/consumeCodePost.ts | 23 ++++++ .../config/passwordlessRecipeConfig.ts | 2 + 3 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts index ff82d9053..7cb3e0b54 100644 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -1,8 +1,13 @@ +import { CustomError } from "@prefabs.tech/fastify-error-handler"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; import { deleteUser, getRequestFromUserContext } from "supertokens-node"; +import UserRoles from "supertokens-node/recipe/userroles"; -import { UserCreateInput } from "src/types"; +import { User, UserCreateInput } from "src/types"; +import { ROLE_USER } from "../../../../constants"; import getUserService from "../../../../lib/getUserService"; +import areRolesExist from "../../../utils/areRolesExist"; import type { FastifyInstance, FastifyRequest } from "fastify"; import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types"; @@ -12,9 +17,20 @@ const consumeCode = ( fastify: FastifyInstance, ): RecipeInterface["consumeCode"] => { return async (input) => { + const roles = (input.userContext.roles || [ + fastify.config.user.role || ROLE_USER, + ]) as string[]; + + if (!(await areRolesExist(roles))) { + throw new CustomError( + `At least one role from ${roles.join(", ")} does not exist.`, + "SIGNUP_FAILED_ERROR", + ); + } + const originalResponse = await originalImplementation.consumeCode(input); - if (originalResponse.status !== "OK" || !originalResponse.createdNewUser) { + if (originalResponse.status !== "OK") { return originalResponse; } @@ -30,11 +46,12 @@ const consumeCode = ( const phoneNumber = originalResponse.user.phoneNumber; - const emailHost = + const emailDomain = + fastify.config.user.fallbackEmailDomain || fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; const email = phoneNumber - ? `${phoneNumber}@${emailHost}` + ? `${phoneNumber}@${emailDomain}` : originalResponse.user.email; if (!email || !phoneNumber) { @@ -43,20 +60,54 @@ const consumeCode = ( throw new Error("Passwordless user missing phoneNumber or email"); } - try { - const user = await userService.create({ - id: originalResponse.user.id, - email, - phoneNumber, - } as UserCreateInput); + let user: User | null | undefined; + + if (originalResponse.createdNewUser) { + try { + user = await userService.create({ + id: originalResponse.user.id, + email, + phoneNumber, + } as UserCreateInput); - if (!user) { - throw new Error("User not found"); + if (!user) { + throw new Error("User not found"); + } + } catch (error) { + await deleteUser(originalResponse.user.id); + + throw error; } - } catch (error) { - await deleteUser(originalResponse.user.id); - throw error; + user.roles = roles; + + originalResponse.user = { + ...originalResponse.user, + ...user, + }; + + for (const role of roles) { + const rolesResponse = await UserRoles.addRoleToUser( + originalResponse.user.id, + role, + ); + + if (rolesResponse.status !== "OK") { + fastify.log.error(rolesResponse.status); + } + } + } else { + await userService + .update(originalResponse.user.id, { + lastLoginAt: formatDate(new Date(Date.now())), + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .catch((error: any) => { + fastify.log.error( + `Unable to update lastLoginAt for userId ${originalResponse.user.id}`, + ); + fastify.log.error(error); + }); } return { diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts new file mode 100644 index 000000000..220794f44 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts @@ -0,0 +1,23 @@ +import { ROLE_USER } from "../../../../constants"; + +import type { FastifyInstance } from "fastify"; +import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; + +const consumeCodePOST = ( + originalImplementation: APIInterface, + fastify: FastifyInstance, +): APIInterface["consumeCodePOST"] => { + return async (input) => { + input.userContext.roles = input.userContext.roles || [ + fastify.config.user.role || ROLE_USER, + ]; + + if (originalImplementation.consumeCodePOST === undefined) { + throw new Error("Should never come here"); + } + + return originalImplementation.consumeCodePOST(input); + }; +}; + +export default consumeCodePOST; diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 99d4f68e5..7544e7a9e 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -4,6 +4,7 @@ import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery" import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; import consumeCode from "./passwordless/consumeCode"; +import consumeCodePOST from "./passwordless/consumeCodePost"; import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import type { @@ -77,6 +78,7 @@ const getPasswordlessRecipeConfig = ( return { ...originalImplementation, + consumeCodePOST: consumeCodePOST(originalImplementation, fastify), ...apiInterface, }; }, From b30fdfd1b1e32145ab13200d8e1310705c5ec8fa Mon Sep 17 00:00:00 2001 From: anvesh Date: Wed, 1 Apr 2026 13:57:56 +0545 Subject: [PATCH 12/19] feat: add twilio configuration options to UserConfig interface --- packages/config/src/types.ts | 13 ------------- packages/user/src/types/config.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 893d63249..59b0eea14 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -57,19 +57,6 @@ interface ApiConfig { rest: { enabled: boolean; }; - twilio?: - | { - accountSid: string; - authToken: string; - from: string; - opts?: Record; - } - | { - accountSid: string; - authToken: string; - messagingServiceSid: string; - opts?: Record; - }; version: string; } diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index 36a94f28e..b759b2286 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -123,6 +123,19 @@ interface UserConfig { name?: string; }; }; + twilio?: + | { + accountSid: string; + authToken: string; + from: string; + opts?: Record; + } + | { + accountSid: string; + authToken: string; + messagingServiceSid: string; + opts?: Record; + }; } export type { EmailOptions, UserConfig }; From e006153eabea209b84f6f860305fa4d098e59bbf Mon Sep 17 00:00:00 2001 From: anvesh Date: Wed, 1 Apr 2026 14:10:31 +0545 Subject: [PATCH 13/19] feat: add local development env support with custom OTP --- .../config/passwordlessRecipeConfig.ts | 116 +++++++++++------- packages/user/src/types/config.ts | 2 + 2 files changed, 74 insertions(+), 44 deletions(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 7544e7a9e..055471b87 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -17,6 +17,8 @@ const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, ): PasswordlessRecipeConfig => { const { config } = fastify; + const isDevelopment = process.env.NODE_ENV === "development"; + const defaultTestOtp = process.env.DEFAULT_TEST_OTP || "123456"; let passwordless: PasswordlessRecipe = {}; @@ -24,36 +26,48 @@ const getPasswordlessRecipeConfig = ( passwordless = config.user.supertokens.recipes.passwordless; } - if (!config.twilio) { - throw new Error( - "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", - ); - } - - if (!("messagingServiceSid" in config.twilio) && !("from" in config.twilio)) { - throw new Error( - "Twilio config requires either messagingServiceSid or from", - ); + let twilioSettings: TwilioServiceConfig | undefined; + + if (!isDevelopment) { + if (!config.user.twilio) { + throw new Error( + "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", + ); + } + + if ( + !("messagingServiceSid" in config.user.twilio) && + !("from" in config.user.twilio) + ) { + throw new Error( + "Twilio config requires either messagingServiceSid or from", + ); + } + + twilioSettings = + "messagingServiceSid" in config.user.twilio + ? { + opts: config.user.twilio.opts, + accountSid: config.user.twilio.accountSid, + authToken: config.user.twilio.authToken, + messagingServiceSid: config.user.twilio.messagingServiceSid, + } + : { + opts: config.user.twilio.opts, + accountSid: config.user.twilio.accountSid, + authToken: config.user.twilio.authToken, + from: config.user.twilio.from, + }; } - const twilioSettings: TwilioServiceConfig = - "messagingServiceSid" in config.twilio - ? { - opts: config.twilio.opts, - accountSid: config.twilio.accountSid, - authToken: config.twilio.authToken, - messagingServiceSid: config.twilio.messagingServiceSid, - } - : { - opts: config.twilio.opts, - accountSid: config.twilio.accountSid, - authToken: config.twilio.authToken, - from: config.twilio.from, - }; - return { contactMethod: passwordless?.contactMethod || "PHONE", flowType: passwordless?.flowType || "USER_INPUT_CODE", + ...(isDevelopment + ? { + getCustomUserInputCode: () => defaultTestOtp, + } + : {}), override: { apis: (originalImplementation) => { const apiInterface: Partial = {}; @@ -110,25 +124,39 @@ const getPasswordlessRecipeConfig = ( }; }, }, - smsDelivery: { - service: new TwilioService({ - twilioSettings, - override: (originalImplementation) => { - return { - ...originalImplementation, - getContent: async (input) => { - return { - body: `Your verification code is: ${input.userInputCode}.`, - toPhoneNumber: input.phoneNumber, - }; - }, - sendRawSms: async (input) => { - await originalImplementation.sendRawSms(input); - }, - }; - }, - }), - }, + ...(isDevelopment + ? { + createAndSendCustomTextMessage: async () => { + fastify.log.info( + `Skipping passwordless SMS delivery in development environment. Use default OTP [${defaultTestOtp}] for testing.`, + ); + }, + } + : { + smsDelivery: { + service: new TwilioService({ + twilioSettings: twilioSettings as TwilioServiceConfig, + override: (originalImplementation) => { + return { + ...originalImplementation, + getContent: async (input) => { + const message = + config.user.twilio?.message || + "Your verification code is:"; + + return { + body: `${message} ${input.userInputCode}.`, + toPhoneNumber: input.phoneNumber, + }; + }, + sendRawSms: async (input) => { + await originalImplementation.sendRawSms(input); + }, + }; + }, + }), + }, + }), }; }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index b759b2286..bb961478c 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -128,11 +128,13 @@ interface UserConfig { accountSid: string; authToken: string; from: string; + message?: string; opts?: Record; } | { accountSid: string; authToken: string; + message?: string; messagingServiceSid: string; opts?: Record; }; From 9323b5a5ead5f295313b191f12b225e76fe1944b Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Apr 2026 12:32:25 +0545 Subject: [PATCH 14/19] refactor: update passwordless configuration structure and improve Twilio integration --- .../config/passwordless/consumeCode.ts | 2 +- .../config/passwordlessRecipeConfig.ts | 61 ++++++++----------- packages/user/src/types/config.ts | 25 +++----- 3 files changed, 34 insertions(+), 54 deletions(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts index 7cb3e0b54..7ac99b47e 100644 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -47,7 +47,7 @@ const consumeCode = ( const phoneNumber = originalResponse.user.phoneNumber; const emailDomain = - fastify.config.user.fallbackEmailDomain || + fastify.config.user.passwordLessConfig.fallbackEmailDomain || fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; const email = phoneNumber diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 055471b87..34d82f43a 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -17,8 +17,8 @@ const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, ): PasswordlessRecipeConfig => { const { config } = fastify; - const isDevelopment = process.env.NODE_ENV === "development"; - const defaultTestOtp = process.env.DEFAULT_TEST_OTP || "123456"; + const isDevelopment = config.user.passwordLessConfig.enableDevMode; + const developmentModeOtp = config.user.passwordLessConfig.devModeOtp; let passwordless: PasswordlessRecipe = {}; @@ -26,38 +26,25 @@ const getPasswordlessRecipeConfig = ( passwordless = config.user.supertokens.recipes.passwordless; } - let twilioSettings: TwilioServiceConfig | undefined; - - if (!isDevelopment) { - if (!config.user.twilio) { - throw new Error( - "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", - ); - } - - if ( - !("messagingServiceSid" in config.user.twilio) && - !("from" in config.user.twilio) - ) { - throw new Error( - "Twilio config requires either messagingServiceSid or from", - ); - } - - twilioSettings = - "messagingServiceSid" in config.user.twilio - ? { - opts: config.user.twilio.opts, - accountSid: config.user.twilio.accountSid, - authToken: config.user.twilio.authToken, - messagingServiceSid: config.user.twilio.messagingServiceSid, - } - : { - opts: config.user.twilio.opts, - accountSid: config.user.twilio.accountSid, - authToken: config.user.twilio.authToken, - from: config.user.twilio.from, - }; + const twilioSettings: TwilioServiceConfig | undefined = isDevelopment + ? undefined + : config.user.passwordLessConfig.twilio; + + if (!isDevelopment && !twilioSettings) { + throw new Error( + "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", + ); + } + + if ( + !isDevelopment && + twilioSettings && + !("from" in twilioSettings) && + !("messagingServiceSid" in twilioSettings) + ) { + throw new Error( + "Twilio config requires either 'from' or 'messagingServiceSid'.", + ); } return { @@ -65,7 +52,7 @@ const getPasswordlessRecipeConfig = ( flowType: passwordless?.flowType || "USER_INPUT_CODE", ...(isDevelopment ? { - getCustomUserInputCode: () => defaultTestOtp, + getCustomUserInputCode: () => developmentModeOtp, } : {}), override: { @@ -128,7 +115,7 @@ const getPasswordlessRecipeConfig = ( ? { createAndSendCustomTextMessage: async () => { fastify.log.info( - `Skipping passwordless SMS delivery in development environment. Use default OTP [${defaultTestOtp}] for testing.`, + `Skipping passwordless SMS delivery in development environment. Use default OTP [${developmentModeOtp}] for testing.`, ); }, } @@ -141,7 +128,7 @@ const getPasswordlessRecipeConfig = ( ...originalImplementation, getContent: async (input) => { const message = - config.user.twilio?.message || + config.user.passwordLessConfig.smsMessage || "Your verification code is:"; return { diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index bb961478c..3c2d9ff1e 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -9,11 +9,13 @@ import type { IsEmailOptions } from "./isEmailOptions"; import type { StrongPasswordOptions } from "./strongPasswordOptions"; import type { User, UserUpdateInput } from "./user"; import type { FastifyRequest } from "fastify"; +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; interface EmailOptions { subject?: string; templateName?: string; } + interface UserConfig { email?: IsEmailOptions; emailOverrides?: { @@ -23,7 +25,6 @@ interface UserConfig { resetPassword?: EmailOptions; resetPasswordNotification?: EmailOptions; }; - fallbackEmailDomain?: string; features?: { profileValidation?: { /** @@ -88,6 +89,13 @@ interface UserConfig { ) => Promise; }; password?: StrongPasswordOptions; + passwordLessConfig: { + devModeOtp: string; + enableDevMode: boolean; + fallbackEmailDomain: string; + smsMessage: string; + twilio: TwilioServiceConfig; + }; permissions?: string[]; photoMaxSizeInMB?: number; role?: string; @@ -123,21 +131,6 @@ interface UserConfig { name?: string; }; }; - twilio?: - | { - accountSid: string; - authToken: string; - from: string; - message?: string; - opts?: Record; - } - | { - accountSid: string; - authToken: string; - message?: string; - messagingServiceSid: string; - opts?: Record; - }; } export type { EmailOptions, UserConfig }; From 5cc001a76aa315564e4050bf8623f4c57089d3bc Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Apr 2026 12:37:16 +0545 Subject: [PATCH 15/19] refactor: make fallbackEmailDomain, smsMessage, and twilio optional in passwordLessConfig --- packages/user/src/types/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index 3c2d9ff1e..61fbf68c3 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -92,9 +92,9 @@ interface UserConfig { passwordLessConfig: { devModeOtp: string; enableDevMode: boolean; - fallbackEmailDomain: string; - smsMessage: string; - twilio: TwilioServiceConfig; + fallbackEmailDomain?: string; + smsMessage?: string; + twilio?: TwilioServiceConfig; }; permissions?: string[]; photoMaxSizeInMB?: number; From c28c3a0f7450548ef7b834934201f6e21bc42ce4 Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 12 May 2026 10:58:48 +0545 Subject: [PATCH 16/19] fix: lint errors --- .../config/passwordless/consumeCode.ts | 13 ++-- .../config/passwordless/consumeCodePost.ts | 4 +- .../config/passwordlessRecipeConfig.ts | 19 +++--- .../recipes/initPasswordlessRecipe.ts | 4 +- packages/user/src/supertokens/types/index.ts | 59 ++++++++++--------- .../supertokens/types/passwordlessRecipe.ts | 22 +++---- packages/user/src/types/config.ts | 13 ++-- 7 files changed, 67 insertions(+), 67 deletions(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts index 7ac99b47e..38d0bfbe3 100644 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -1,17 +1,16 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { formatDate } from "@prefabs.tech/fastify-slonik"; +import { User, UserCreateInput } from "src/types"; import { deleteUser, getRequestFromUserContext } from "supertokens-node"; import UserRoles from "supertokens-node/recipe/userroles"; -import { User, UserCreateInput } from "src/types"; - import { ROLE_USER } from "../../../../constants"; import getUserService from "../../../../lib/getUserService"; import areRolesExist from "../../../utils/areRolesExist"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types"; - const consumeCode = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -60,13 +59,13 @@ const consumeCode = ( throw new Error("Passwordless user missing phoneNumber or email"); } - let user: User | null | undefined; + let user: null | undefined | User; if (originalResponse.createdNewUser) { try { user = await userService.create({ - id: originalResponse.user.id, email, + id: originalResponse.user.id, phoneNumber, } as UserCreateInput); diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts index 220794f44..881674555 100644 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts @@ -1,8 +1,8 @@ -import { ROLE_USER } from "../../../../constants"; - import type { FastifyInstance } from "fastify"; import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; +import { ROLE_USER } from "../../../../constants"; + const consumeCodePOST = ( originalImplementation: APIInterface, fastify: FastifyInstance, diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 34d82f43a..1b4fe944a 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -1,18 +1,17 @@ -import { FastifyInstance } from "fastify"; -import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery"; - -import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; - -import consumeCode from "./passwordless/consumeCode"; -import consumeCodePOST from "./passwordless/consumeCodePost"; - import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import type { APIInterface, - RecipeInterface, TypeInput as PasswordlessRecipeConfig, + RecipeInterface, } from "supertokens-node/recipe/passwordless/types"; +import { FastifyInstance } from "fastify"; +import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; +import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery"; + +import consumeCode from "./passwordless/consumeCode"; +import consumeCodePOST from "./passwordless/consumeCodePost"; + const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, ): PasswordlessRecipeConfig => { @@ -122,7 +121,6 @@ const getPasswordlessRecipeConfig = ( : { smsDelivery: { service: new TwilioService({ - twilioSettings: twilioSettings as TwilioServiceConfig, override: (originalImplementation) => { return { ...originalImplementation, @@ -141,6 +139,7 @@ const getPasswordlessRecipeConfig = ( }, }; }, + twilioSettings: twilioSettings as TwilioServiceConfig, }), }, }), diff --git a/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts index f65c109d6..77229a706 100644 --- a/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts +++ b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts @@ -1,10 +1,10 @@ import { FastifyInstance } from "fastify"; import Passwordless from "supertokens-node/recipe/passwordless"; -import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig"; - import type { SupertokensRecipes } from "../types"; +import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig"; + const init = (fastify: FastifyInstance) => { const passwordless: SupertokensRecipes["passwordless"] = fastify.config.user.supertokens.recipes?.passwordless; diff --git a/packages/user/src/supertokens/types/index.ts b/packages/user/src/supertokens/types/index.ts index c9906fef4..4492e8c2a 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -1,3 +1,11 @@ +import type { FastifyInstance } from "fastify"; +import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; +import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; +import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; +import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import type { TypeInput as UserRolesRecipeConfig } from "supertokens-node/recipe/userroles/types"; + import { Apple, Facebook, @@ -9,35 +17,6 @@ import type { EmailVerificationRecipe } from "./emailVerificationRecipe"; import type { PasswordlessRecipe } from "./passwordlessRecipe"; import type { SessionRecipe } from "./sessionRecipe"; import type { ThirdPartyEmailPasswordRecipe } from "./thirdPartyEmailPasswordRecipe"; -import type { FastifyInstance } from "fastify"; -import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; -import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; -import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; -import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; -import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; -import type { TypeInput as UserRolesRecipeConfig } from "supertokens-node/recipe/userroles/types"; - -interface SupertokensRecipes { - emailVerification?: - | EmailVerificationRecipe - | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig); - passwordless?: - | PasswordlessRecipe - | ((fastify: FastifyInstance) => PasswordlessRecipeConfig); - session?: SessionRecipe | ((fastify: FastifyInstance) => SessionRecipeConfig); - userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; - thirdPartyEmailPassword?: - | ThirdPartyEmailPasswordRecipe - | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig); -} - -interface SupertokensThirdPartyProvider { - apple?: Parameters[0][]; - facebook?: Parameters[0]; - github?: Parameters[0]; - google?: Parameters[0]; - custom?: TypeProvider[]; -} interface SupertokensConfig { apiBasePath?: string; @@ -59,6 +38,9 @@ interface SupertokensRecipes { emailVerification?: | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) | EmailVerificationRecipe; + passwordless?: + | ((fastify: FastifyInstance) => PasswordlessRecipeConfig) + | PasswordlessRecipe; session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; thirdPartyEmailPassword?: | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) @@ -66,6 +48,25 @@ interface SupertokensRecipes { userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; } +interface SupertokensRecipes { + emailVerification?: + | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) + | EmailVerificationRecipe; + session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; + thirdPartyEmailPassword?: + | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) + | ThirdPartyEmailPasswordRecipe; + userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; +} + +interface SupertokensThirdPartyProvider { + apple?: Parameters[0][]; + custom?: TypeProvider[]; + facebook?: Parameters[0]; + github?: Parameters[0]; + google?: Parameters[0]; +} + interface SupertokensThirdPartyProvider { apple?: Parameters[0][]; custom?: TypeProvider[]; diff --git a/packages/user/src/supertokens/types/passwordlessRecipe.ts b/packages/user/src/supertokens/types/passwordlessRecipe.ts index 13c202106..a2e2b2f9a 100644 --- a/packages/user/src/supertokens/types/passwordlessRecipe.ts +++ b/packages/user/src/supertokens/types/passwordlessRecipe.ts @@ -1,10 +1,10 @@ -import { FastifyInstance } from "fastify"; - import type { APIInterface, RecipeInterface, } from "supertokens-node/recipe/passwordless/types"; +import { FastifyInstance } from "fastify"; + type APIInterfaceWrapper = { [key in keyof APIInterface]?: ( originalImplementation: APIInterface, @@ -12,15 +12,8 @@ type APIInterfaceWrapper = { ) => APIInterface[key]; }; -type RecipeInterfaceWrapper = { - [key in keyof RecipeInterface]?: ( - originalImplementation: RecipeInterface, - fastify: FastifyInstance, - ) => RecipeInterface[key]; -}; - interface PasswordlessRecipe { - contactMethod?: "EMAIL" | "PHONE" | "EMAIL_OR_PHONE"; + contactMethod?: "EMAIL" | "EMAIL_OR_PHONE" | "PHONE"; flowType?: "USER_INPUT_CODE"; override?: { apis?: APIInterfaceWrapper; @@ -28,4 +21,11 @@ interface PasswordlessRecipe { }; } -export type { APIInterfaceWrapper, RecipeInterfaceWrapper, PasswordlessRecipe }; +type RecipeInterfaceWrapper = { + [key in keyof RecipeInterface]?: ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, + ) => RecipeInterface[key]; +}; + +export type { APIInterfaceWrapper, PasswordlessRecipe, RecipeInterfaceWrapper }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index a9103f394..d3c89342a 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -1,15 +1,16 @@ -import invitationHandlers from "../model/invitations/handlers"; -import InvitationService from "../model/invitations/service"; -import userHandlers from "../model/users/handlers"; -import UserService from "../model/users/service"; +import type { FastifyRequest } from "fastify"; +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import type { SupertokensConfig } from "../supertokens"; import type { Invitation } from "./invitation"; import type { IsEmailOptions } from "./isEmailOptions"; import type { StrongPasswordOptions } from "./strongPasswordOptions"; import type { User, UserUpdateInput } from "./user"; -import type { FastifyRequest } from "fastify"; -import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; + +import invitationHandlers from "../model/invitations/handlers"; +import InvitationService from "../model/invitations/service"; +import userHandlers from "../model/users/handlers"; +import UserService from "../model/users/service"; interface EmailOptions { subject?: string; From 6275d4d2c8b3ea654aaf5e2c90d4d02037715f67 Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 12 May 2026 16:00:03 +0545 Subject: [PATCH 17/19] feat: add development mode bypass for SMS in passwordless config --- .../config/passwordlessRecipeConfig.ts | 36 ++++++++++++++++--- packages/user/src/types/config.ts | 1 + 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index 1b4fe944a..e888746f1 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -19,6 +19,13 @@ const getPasswordlessRecipeConfig = ( const isDevelopment = config.user.passwordLessConfig.enableDevMode; const developmentModeOtp = config.user.passwordLessConfig.devModeOtp; + const isDevelopmentNumber = (phoneNumber: string) => { + const developmentModeNumbers = + config.user.passwordLessConfig.bypassSmsFor || []; + + return developmentModeNumbers.includes(phoneNumber); + }; + let passwordless: PasswordlessRecipe = {}; if (typeof config.user.supertokens.recipes?.passwordless === "object") { @@ -49,11 +56,15 @@ const getPasswordlessRecipeConfig = ( return { contactMethod: passwordless?.contactMethod || "PHONE", flowType: passwordless?.flowType || "USER_INPUT_CODE", - ...(isDevelopment - ? { - getCustomUserInputCode: () => developmentModeOtp, - } - : {}), + getCustomUserInputCode: async (userContext) => { + const phoneNumber = userContext?.phoneNumber as string | undefined; + + if (isDevelopment || (phoneNumber && isDevelopmentNumber(phoneNumber))) { + return developmentModeOtp; + } + + return Math.floor(100_000 + Math.random() * 900_000).toString(); + }, override: { apis: (originalImplementation) => { const apiInterface: Partial = {}; @@ -79,6 +90,13 @@ const getPasswordlessRecipeConfig = ( return { ...originalImplementation, consumeCodePOST: consumeCodePOST(originalImplementation, fastify), + createCodePOST: async (input) => { + if ("phoneNumber" in input) { + input.userContext.phoneNumber = input.phoneNumber; + } + + return originalImplementation.createCodePOST!(input); + }, ...apiInterface, }; }, @@ -135,6 +153,14 @@ const getPasswordlessRecipeConfig = ( }; }, sendRawSms: async (input) => { + if (isDevelopmentNumber(input.toPhoneNumber)) { + fastify.log.info( + `Skipping SMS for test number ${input.toPhoneNumber}. SMS body: [${input.body}]`, + ); + + return; + } + await originalImplementation.sendRawSms(input); }, }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d3c89342a..f69a1ff5d 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -91,6 +91,7 @@ interface UserConfig { }; password?: StrongPasswordOptions; passwordLessConfig: { + bypassSmsFor?: string[]; devModeOtp: string; enableDevMode: boolean; fallbackEmailDomain?: string; From 362fa1c7bcca0fde72f0a350a12c7efc1878322f Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 12 May 2026 16:34:41 +0545 Subject: [PATCH 18/19] chore: add comment to use supertokens otp genration logic --- .../src/supertokens/recipes/config/passwordlessRecipeConfig.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index e888746f1..dcf8d721b 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -63,6 +63,7 @@ const getPasswordlessRecipeConfig = ( return developmentModeOtp; } + // TODO [AJ 20260512] Check how supertokens generates OTP by default and use that logic here return Math.floor(100_000 + Math.random() * 900_000).toString(); }, override: { From d0a0bbb9698a615f3f26bdfd907f7c43f27656bc Mon Sep 17 00:00:00 2001 From: uddhab Date: Tue, 12 May 2026 17:20:03 +0545 Subject: [PATCH 19/19] chore(user): bump version to 0.94.0-beta.2 for beta release --- packages/user/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/user/package.json b/packages/user/package.json index 665a85c39..06e028c65 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-user", - "version": "0.94.0", + "version": "0.94.0-beta.2", "description": "Fastify user plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/user#readme", "repository": { @@ -81,4 +81,4 @@ "engines": { "node": ">=20" } -} \ No newline at end of file +}