From 2e137cf57a7d22fb9af7e599b4eaeaef0d6e3796 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 14:10:39 +0545 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 b82089199a0a08ea022d71a973ed82fd0cad844b Mon Sep 17 00:00:00 2001 From: uddhab Date: Thu, 2 Apr 2026 11:50:38 +0545 Subject: [PATCH 14/14] chore(user): bump version to 0.94.0-beta.1 for beta release --- packages/user/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/user/package.json b/packages/user/package.json index 73efc146b..438ad60ae 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-user", - "version": "0.93.5", + "version": "0.94.0-beta.1", "description": "Fastify user plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/user#readme", "repository": {