From 2e137cf57a7d22fb9af7e599b4eaeaef0d6e3796 Mon Sep 17 00:00:00 2001 From: samarajya Date: Mon, 16 Mar 2026 14:10:39 +0545 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 02eff4cb320014507d4ca087cbbc35b7a08786ef Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Jul 2026 15:08:39 +0545 Subject: [PATCH 19/37] feat: add twilio dependency for SMS functionality --- packages/user/package.json | 1 + pnpm-lock.yaml | 53 +++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/user/package.json b/packages/user/package.json index 426528a47..51e468056 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "humps": "2.0.1", + "twilio": "6.0.0", "validator": "13.15.35" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ce9811f..4990743e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,6 +529,9 @@ importers: humps: specifier: 2.0.1 version: 2.0.1 + twilio: + specifier: 6.0.0 + version: 6.0.0 validator: specifier: 13.15.35 version: 13.15.35 @@ -4929,6 +4932,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -5100,6 +5107,7 @@ packages: scmp@2.1.0: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -5178,6 +5186,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -5190,6 +5202,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -5480,6 +5496,10 @@ packages: resolution: {integrity: sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==} engines: {node: '>=14.0'} + twilio@6.0.0: + resolution: {integrity: sha512-MAie5DJ3KLpcKlDaYtNzsKMQXcCi+YHWKvZjuSpm27vJAO/l8PanJA0LkkJ03sbh+Kwe5NeL0Q2+y6IjNUYeUA==} + engines: {node: '>=20.0.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -9392,7 +9412,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -11172,6 +11192,11 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -11467,6 +11492,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -11490,6 +11520,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -11826,6 +11864,19 @@ snapshots: - debug - supports-color + twilio@6.0.0: + dependencies: + axios: 1.13.5 + dayjs: 1.11.18 + https-proxy-agent: 5.0.1 + jsonwebtoken: 9.0.3 + qs: 6.15.3 + scmp: 2.1.0 + xmlbuilder: 13.0.2 + transitivePeerDependencies: + - debug + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From 013b84fda1fd30d99f105fdfaa3bb391ac495d5d Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Jul 2026 15:11:21 +0545 Subject: [PATCH 20/37] feat: update UserConfig with passwordless login and Twilio configuration --- packages/user/src/types/config.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index f69a1ff5d..0de64eda4 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -17,6 +17,13 @@ interface EmailOptions { templateName?: string; } +type TwilioConfig = Omit< + TwilioServiceConfig, + "from" | "messagingServiceSid" +> & { + verifyServiceSid: string; +}; + interface UserConfig { email?: IsEmailOptions; emailOverrides?: { @@ -27,6 +34,9 @@ interface UserConfig { resetPasswordNotification?: EmailOptions; }; features?: { + passwordlessLogin?: { + enabled?: boolean; + }; profileValidation?: { /** * @default false @@ -90,13 +100,13 @@ interface UserConfig { ) => Promise; }; password?: StrongPasswordOptions; - passwordLessConfig: { + passwordLessConfig?: { bypassSmsFor?: string[]; devModeOtp: string; enableDevMode: boolean; fallbackEmailDomain?: string; smsMessage?: string; - twilio?: TwilioServiceConfig; + twilio?: TwilioConfig; }; permissions?: string[]; photoMaxSizeInMB?: number; @@ -135,4 +145,4 @@ interface UserConfig { }; } -export type { EmailOptions, UserConfig }; +export type { EmailOptions, TwilioConfig, UserConfig }; From 34b814d2615de56b7d3f310d94c74d530cf80a8d Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Jul 2026 15:12:57 +0545 Subject: [PATCH 21/37] feat: integrate Twilio Verify for passwordless authentication and update consumeCode logic --- .../config/passwordless/consumeCode.ts | 4 +- .../config/passwordless/consumeCodePost.ts | 115 ++++++++++++++++- .../config/passwordlessRecipeConfig.ts | 117 +++++++++++------- 3 files changed, 186 insertions(+), 50 deletions(-) diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts index 38d0bfbe3..65ba245d1 100644 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -46,7 +46,7 @@ const consumeCode = ( const phoneNumber = originalResponse.user.phoneNumber; const emailDomain = - fastify.config.user.passwordLessConfig.fallbackEmailDomain || + fastify.config.user.passwordLessConfig?.fallbackEmailDomain || fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; const email = phoneNumber @@ -56,7 +56,7 @@ const consumeCode = ( if (!email || !phoneNumber) { await deleteUser(originalResponse.user.id); - throw new Error("Passwordless user missing phoneNumber or email"); + throw new Error("Passwordless user missing phone number or email"); } let user: null | undefined | User; diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts index 881674555..392959f57 100644 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts @@ -1,8 +1,31 @@ import type { FastifyInstance } from "fastify"; import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import twilio from "twilio"; + import { ROLE_USER } from "../../../../constants"; +export const TWILIO_VERIFY_PLACEHOLDER_CODE = "000000"; + +const enrichResult = ( + result: Awaited>>, + phoneNumber: string, + fallbackEmailDomain: string, +) => { + if (result.status !== "OK") { + return result; + } + + return { + ...result, + user: { + ...result.user, + email: result.user.email ?? `${phoneNumber}@${fallbackEmailDomain}`, + }, + }; +}; + const consumeCodePOST = ( originalImplementation: APIInterface, fastify: FastifyInstance, @@ -16,7 +39,97 @@ const consumeCodePOST = ( throw new Error("Should never come here"); } - return originalImplementation.consumeCodePOST(input); + // Only handle user input code flows, not magic link flows + if (!("userInputCode" in input) || input.userInputCode === undefined) { + return originalImplementation.consumeCodePOST(input); + } + + const { config } = fastify; + + if (!config.user.passwordLessConfig) { + throw new Error("Passwordless recipe config is missing"); + } + + const isDevelopment = config.user.passwordLessConfig.enableDevMode; + + // Look up the device to retrieve the associated phone number + const deviceContext = await Passwordless.listCodesByPreAuthSessionId({ + preAuthSessionId: input.preAuthSessionId, + }); + + if (!deviceContext || !deviceContext.phoneNumber) { + return { status: "RESTART_FLOW_ERROR" }; + } + + const { phoneNumber } = deviceContext; + const bypassNumbers = config.user.passwordLessConfig.bypassSmsFor ?? []; + + const fallbackEmailDomain = + config.user.passwordLessConfig.fallbackEmailDomain ?? ""; + + // In dev mode or for bypassed numbers, skip Twilio Verify and let + // SuperTokens verify the code directly (uses devModeOtp) + if (isDevelopment || bypassNumbers.includes(phoneNumber)) { + return enrichResult( + await originalImplementation.consumeCodePOST(input), + phoneNumber, + fallbackEmailDomain, + ); + } + + const verifyServiceSid = + config.user.passwordLessConfig?.twilio?.verifyServiceSid; + + if (!verifyServiceSid) { + fastify.log.error("TWILIO_VERIFY_SERVICE_SID is not configured"); + return { status: "RESTART_FLOW_ERROR" }; + } + + const twilioConfig = config.user.passwordLessConfig.twilio; + + if (!twilioConfig) { + fastify.log.error("Twilio config is missing for passwordless recipe"); + return { status: "RESTART_FLOW_ERROR" }; + } + + if (!twilioConfig.accountSid || !twilioConfig.authToken) { + fastify.log.error( + "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are required for passwordless verification", + ); + return { status: "RESTART_FLOW_ERROR" }; + } + + const twilioClient = twilio( + twilioConfig.accountSid, + twilioConfig.authToken, + ); + + try { + const check = await twilioClient.verify.v2 + .services(verifyServiceSid) + .verificationChecks.create({ + code: input.userInputCode, + to: phoneNumber, + }); + + return check.status === "approved" + ? enrichResult( + await originalImplementation.consumeCodePOST({ + ...input, + userInputCode: TWILIO_VERIFY_PLACEHOLDER_CODE, + }), + phoneNumber, + fallbackEmailDomain, + ) + : { + failedCodeInputAttemptCount: 1, + maximumCodeInputAttempts: 5, + status: "INCORRECT_USER_INPUT_CODE_ERROR", + }; + } catch (error) { + fastify.log.error(error, "Twilio Verify verification check failed"); + return { status: "RESTART_FLOW_ERROR" }; + } }; }; diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts index dcf8d721b..8ad942693 100644 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -1,4 +1,3 @@ -import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import type { APIInterface, TypeInput as PasswordlessRecipeConfig, @@ -7,21 +6,37 @@ import type { import { FastifyInstance } from "fastify"; import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; -import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery"; +import { TwilioConfig } from "src/types"; +import twilio from "twilio"; import consumeCode from "./passwordless/consumeCode"; -import consumeCodePOST from "./passwordless/consumeCodePost"; +import consumeCodePOST, { + TWILIO_VERIFY_PLACEHOLDER_CODE, +} from "./passwordless/consumeCodePost"; + +// Since Supertokens directly does not support Twilio verify api, we need to override the consumeCodePOST api to integrate with Twilio Verify. The consumeCode function is also overridden to create a user in our database when a new user is created in Supertokens after successful verification. + +// How it works: +// 1. When a user tries to sign in/sign up, they hit the createCodePOST API which requests an OTP from Twilio Verify for the provided phone number. +// 2. To satisfy Supertokens' requirement of having a user input code, we store a TWILIO_VERIFY_PLACEHOLDER_CODE in Supertokens instead of the actual OTP. +// 3. When the user submits the OTP they received, we hit the consumeCodePOST API. Here, we first verify the OTP with Twilio Verify. If Twilio approves, we then call the original consumeCodePOST with the TWILIO_VERIFY_PLACEHOLDER_CODE, which allows Supertokens to complete its flow successfully. +// 4. In the consumeCode function, if a new user was created by Supertokens, we create a corresponding user in our database with the phone number and a synthetic email (in the format phoneNumber@fallbackEmailDomain) since Supertokens requires an email field. const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, ): PasswordlessRecipeConfig => { const { config } = fastify; + + if (!config.user.passwordLessConfig) { + throw new Error("Passwordless recipe config is missing"); + } + const isDevelopment = config.user.passwordLessConfig.enableDevMode; const developmentModeOtp = config.user.passwordLessConfig.devModeOtp; const isDevelopmentNumber = (phoneNumber: string) => { const developmentModeNumbers = - config.user.passwordLessConfig.bypassSmsFor || []; + config.user.passwordLessConfig?.bypassSmsFor || []; return developmentModeNumbers.includes(phoneNumber); }; @@ -32,7 +47,7 @@ const getPasswordlessRecipeConfig = ( passwordless = config.user.supertokens.recipes.passwordless; } - const twilioSettings: TwilioServiceConfig | undefined = isDevelopment + const twilioSettings: TwilioConfig | undefined = isDevelopment ? undefined : config.user.passwordLessConfig.twilio; @@ -42,17 +57,6 @@ const getPasswordlessRecipeConfig = ( ); } - if ( - !isDevelopment && - twilioSettings && - !("from" in twilioSettings) && - !("messagingServiceSid" in twilioSettings) - ) { - throw new Error( - "Twilio config requires either 'from' or 'messagingServiceSid'.", - ); - } - return { contactMethod: passwordless?.contactMethod || "PHONE", flowType: passwordless?.flowType || "USER_INPUT_CODE", @@ -63,8 +67,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(); + return TWILIO_VERIFY_PLACEHOLDER_CODE; }, override: { apis: (originalImplementation) => { @@ -139,35 +142,55 @@ const getPasswordlessRecipeConfig = ( } : { smsDelivery: { - service: new TwilioService({ - override: (originalImplementation) => { - return { - ...originalImplementation, - getContent: async (input) => { - const message = - config.user.passwordLessConfig.smsMessage || - "Your verification code is:"; - - return { - body: `${message} ${input.userInputCode}.`, - toPhoneNumber: input.phoneNumber, - }; - }, - 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); - }, - }; - }, - twilioSettings: twilioSettings as TwilioServiceConfig, - }), + override: (originalImplementation) => { + return { + ...originalImplementation, + sendSms: async (input: { phoneNumber: string }) => { + if (isDevelopmentNumber(input.phoneNumber)) { + fastify.log.info( + `Skipping SMS for test number ${input.phoneNumber}.`, + ); + + return; + } + + const verifyServiceSid = + config.user.passwordLessConfig?.twilio?.verifyServiceSid; + + if (!verifyServiceSid) { + throw new Error( + "TWILIO_VERIFY_SERVICE_SID is not configured", + ); + } + + const { accountSid, authToken } = + twilioSettings as TwilioConfig; + + if (!accountSid || !authToken) { + throw new Error( + "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are required for passwordless SMS delivery", + ); + } + + const twilioClient = twilio(accountSid, authToken); + + try { + await twilioClient.verify.v2 + .services(verifyServiceSid) + .verifications.create({ + channel: "sms", + to: input.phoneNumber, + }); + } catch (error) { + fastify.log.error( + error, + "Twilio Verify failed to send OTP", + ); + throw error; + } + }, + }; + }, }, }), }; From c4a5536e0bb7b290d37599410d7b00f1884101c4 Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Jul 2026 15:13:19 +0545 Subject: [PATCH 22/37] feat: conditionally add passwordless recipe to recipe list --- packages/user/src/supertokens/recipes/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 6daf9f76b..93b3d0b12 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -9,12 +9,15 @@ import initUserRolesRecipe from "./initUserRolesRecipe"; const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { const recipeList = [ - initPasswordlessRecipe(fastify), initSessionRecipe(fastify), initThirdPartyEmailPassword(fastify), initUserRolesRecipe(fastify), ]; + if (fastify.config.user.features?.passwordlessLogin?.enabled) { + recipeList.push(initPasswordlessRecipe(fastify)); + } + if (fastify.config.user.features?.signUp?.emailVerification) { recipeList.push(initEmailVerificationRecipe(fastify)); } From 33fb26824fc8d32d768176f76aa5fa7354afe6ad Mon Sep 17 00:00:00 2001 From: anvesh Date: Thu, 2 Jul 2026 15:33:04 +0545 Subject: [PATCH 23/37] feat: remove smsMessage from UserConfig --- packages/user/src/types/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index 0de64eda4..4c1b5fbe6 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -105,7 +105,6 @@ interface UserConfig { devModeOtp: string; enableDevMode: boolean; fallbackEmailDomain?: string; - smsMessage?: string; twilio?: TwilioConfig; }; permissions?: string[]; From 064684511c876b7d8dc4013de93f2906affaaa17 Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 28 Jul 2026 13:19:22 +0545 Subject: [PATCH 24/37] refactor(user): replace passwordless recipe with generic recipe factory --- .../config/passwordless/consumeCode.ts | 122 ----------- .../config/passwordless/consumeCodePost.ts | 134 ------------ .../config/passwordlessRecipeConfig.ts | 199 ------------------ .../recipes/initPasswordlessRecipe.ts | 19 -- packages/user/src/supertokens/types/index.ts | 29 +-- .../supertokens/types/passwordlessRecipe.ts | 31 --- packages/user/src/types/config.ts | 20 +- packages/user/src/types/user.ts | 2 + 8 files changed, 8 insertions(+), 548 deletions(-) delete mode 100644 packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts delete mode 100644 packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts delete mode 100644 packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts delete mode 100644 packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts delete mode 100644 packages/user/src/supertokens/types/passwordlessRecipe.ts diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts deleted file mode 100644 index b9795b0a1..000000000 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts +++ /dev/null @@ -1,122 +0,0 @@ -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 { ROLE_USER } from "../../../../constants"; -import getUserService from "../../../../lib/getUserService"; -import areRolesExist from "../../../utils/areRolesExist"; - -const consumeCode = ( - originalImplementation: RecipeInterface, - 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") { - 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 emailDomain = - fastify.config.user.passwordLessConfig?.fallbackEmailDomain || - fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; - - const email = phoneNumber - ? `${phoneNumber}@${emailDomain}` - : originalResponse.user.email; - - if (!email || !phoneNumber) { - await deleteUser(originalResponse.user.id); - - throw new Error("Passwordless user missing phone number or email"); - } - - let user: null | undefined | User; - - if (originalResponse.createdNewUser) { - try { - user = await userService.create({ - email, - id: originalResponse.user.id, - phoneNumber, - } as UserCreateInput); - - if (!user) { - throw new Error("User not found"); - } - } 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 { - ...originalResponse, - user: { - ...originalResponse.user, - email, - phoneNumber, - }, - }; - }; -}; - -export default consumeCode; diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts deleted file mode 100644 index 3d32fa8ca..000000000 --- a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { FastifyInstance } from "fastify"; -import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; - -import Passwordless from "supertokens-node/recipe/passwordless"; -import twilio from "twilio"; - -import { ROLE_USER } from "../../../../constants"; - -export const TWILIO_VERIFY_PLACEHOLDER_CODE = "000000"; - -const enrichResult = ( - result: Awaited>>, - phoneNumber: string, - fallbackEmailDomain: string, -) => { - if (result.status !== "OK") { - return result; - } - - return { - ...result, - user: { - ...result.user, - email: result.user.email ?? `${phoneNumber}@${fallbackEmailDomain}`, - }, - }; -}; - -const consumeCodePOST = ( - originalImplementation: APIInterface, - fastify: FastifyInstance, -): APIInterface["consumeCodePOST"] => { - return async (input) => { - input.userContext.roles ||= [fastify.config.user.role || ROLE_USER]; - - if (originalImplementation.consumeCodePOST === undefined) { - throw new Error("Should never come here"); - } - - // Only handle user input code flows, not magic link flows - if (!("userInputCode" in input) || input.userInputCode === undefined) { - return originalImplementation.consumeCodePOST(input); - } - - const { config } = fastify; - - if (!config.user.passwordLessConfig) { - throw new Error("Passwordless recipe config is missing"); - } - - const isDevelopment = config.user.passwordLessConfig.enableDevMode; - - // Look up the device to retrieve the associated phone number - const deviceContext = await Passwordless.listCodesByPreAuthSessionId({ - preAuthSessionId: input.preAuthSessionId, - }); - - if (!deviceContext || !deviceContext.phoneNumber) { - return { status: "RESTART_FLOW_ERROR" }; - } - - const { phoneNumber } = deviceContext; - const bypassNumbers = config.user.passwordLessConfig.bypassSmsFor ?? []; - - const fallbackEmailDomain = - config.user.passwordLessConfig.fallbackEmailDomain ?? ""; - - // In dev mode or for bypassed numbers, skip Twilio Verify and let - // SuperTokens verify the code directly (uses devModeOtp) - if (isDevelopment || bypassNumbers.includes(phoneNumber)) { - return enrichResult( - await originalImplementation.consumeCodePOST(input), - phoneNumber, - fallbackEmailDomain, - ); - } - - const verifyServiceSid = - config.user.passwordLessConfig?.twilio?.verifyServiceSid; - - if (!verifyServiceSid) { - fastify.log.error("TWILIO_VERIFY_SERVICE_SID is not configured"); - return { status: "RESTART_FLOW_ERROR" }; - } - - const twilioConfig = config.user.passwordLessConfig.twilio; - - if (!twilioConfig) { - fastify.log.error("Twilio config is missing for passwordless recipe"); - return { status: "RESTART_FLOW_ERROR" }; - } - - if (!twilioConfig.accountSid || !twilioConfig.authToken) { - fastify.log.error( - "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are required for passwordless verification", - ); - return { status: "RESTART_FLOW_ERROR" }; - } - - const twilioClient = twilio( - twilioConfig.accountSid, - twilioConfig.authToken, - ); - - try { - const check = await twilioClient.verify.v2 - .services(verifyServiceSid) - .verificationChecks.create({ - code: input.userInputCode, - to: phoneNumber, - }); - - return check.status === "approved" - ? enrichResult( - await originalImplementation.consumeCodePOST({ - ...input, - userInputCode: TWILIO_VERIFY_PLACEHOLDER_CODE, - }), - phoneNumber, - fallbackEmailDomain, - ) - : { - failedCodeInputAttemptCount: 1, - maximumCodeInputAttempts: 5, - status: "INCORRECT_USER_INPUT_CODE_ERROR", - }; - } catch (error) { - fastify.log.error(error, "Twilio Verify verification check failed"); - return { status: "RESTART_FLOW_ERROR" }; - } - }; -}; - -export default consumeCodePOST; diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts deleted file mode 100644 index 8ad942693..000000000 --- a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { - APIInterface, - TypeInput as PasswordlessRecipeConfig, - RecipeInterface, -} from "supertokens-node/recipe/passwordless/types"; - -import { FastifyInstance } from "fastify"; -import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; -import { TwilioConfig } from "src/types"; -import twilio from "twilio"; - -import consumeCode from "./passwordless/consumeCode"; -import consumeCodePOST, { - TWILIO_VERIFY_PLACEHOLDER_CODE, -} from "./passwordless/consumeCodePost"; - -// Since Supertokens directly does not support Twilio verify api, we need to override the consumeCodePOST api to integrate with Twilio Verify. The consumeCode function is also overridden to create a user in our database when a new user is created in Supertokens after successful verification. - -// How it works: -// 1. When a user tries to sign in/sign up, they hit the createCodePOST API which requests an OTP from Twilio Verify for the provided phone number. -// 2. To satisfy Supertokens' requirement of having a user input code, we store a TWILIO_VERIFY_PLACEHOLDER_CODE in Supertokens instead of the actual OTP. -// 3. When the user submits the OTP they received, we hit the consumeCodePOST API. Here, we first verify the OTP with Twilio Verify. If Twilio approves, we then call the original consumeCodePOST with the TWILIO_VERIFY_PLACEHOLDER_CODE, which allows Supertokens to complete its flow successfully. -// 4. In the consumeCode function, if a new user was created by Supertokens, we create a corresponding user in our database with the phone number and a synthetic email (in the format phoneNumber@fallbackEmailDomain) since Supertokens requires an email field. - -const getPasswordlessRecipeConfig = ( - fastify: FastifyInstance, -): PasswordlessRecipeConfig => { - const { config } = fastify; - - if (!config.user.passwordLessConfig) { - throw new Error("Passwordless recipe config is missing"); - } - - 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") { - passwordless = config.user.supertokens.recipes.passwordless; - } - - const twilioSettings: TwilioConfig | 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.", - ); - } - - return { - contactMethod: passwordless?.contactMethod || "PHONE", - flowType: passwordless?.flowType || "USER_INPUT_CODE", - getCustomUserInputCode: async (userContext) => { - const phoneNumber = userContext?.phoneNumber as string | undefined; - - if (isDevelopment || (phoneNumber && isDevelopmentNumber(phoneNumber))) { - return developmentModeOtp; - } - - return TWILIO_VERIFY_PLACEHOLDER_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, - consumeCodePOST: consumeCodePOST(originalImplementation, fastify), - createCodePOST: async (input) => { - if ("phoneNumber" in input) { - input.userContext.phoneNumber = input.phoneNumber; - } - - return originalImplementation.createCodePOST!(input); - }, - ...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, - }; - }, - }, - ...(isDevelopment - ? { - createAndSendCustomTextMessage: async () => { - fastify.log.info( - `Skipping passwordless SMS delivery in development environment. Use default OTP [${developmentModeOtp}] for testing.`, - ); - }, - } - : { - smsDelivery: { - override: (originalImplementation) => { - return { - ...originalImplementation, - sendSms: async (input: { phoneNumber: string }) => { - if (isDevelopmentNumber(input.phoneNumber)) { - fastify.log.info( - `Skipping SMS for test number ${input.phoneNumber}.`, - ); - - return; - } - - const verifyServiceSid = - config.user.passwordLessConfig?.twilio?.verifyServiceSid; - - if (!verifyServiceSid) { - throw new Error( - "TWILIO_VERIFY_SERVICE_SID is not configured", - ); - } - - const { accountSid, authToken } = - twilioSettings as TwilioConfig; - - if (!accountSid || !authToken) { - throw new Error( - "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are required for passwordless SMS delivery", - ); - } - - const twilioClient = twilio(accountSid, authToken); - - try { - await twilioClient.verify.v2 - .services(verifyServiceSid) - .verifications.create({ - channel: "sms", - to: input.phoneNumber, - }); - } catch (error) { - fastify.log.error( - error, - "Twilio Verify failed to send OTP", - ); - throw error; - } - }, - }; - }, - }, - }), - }; -}; - -export default getPasswordlessRecipeConfig; diff --git a/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts deleted file mode 100644 index 77229a706..000000000 --- a/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { FastifyInstance } from "fastify"; -import Passwordless from "supertokens-node/recipe/passwordless"; - -import type { SupertokensRecipes } from "../types"; - -import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig"; - -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 4492e8c2a..2aef8aaeb 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -1,10 +1,10 @@ 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 type { RecipeListFunction } from "supertokens-node/types"; import { Apple, @@ -14,7 +14,6 @@ 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"; @@ -34,19 +33,9 @@ interface SupertokensConfig { setErrorHandler?: boolean; } -interface SupertokensRecipes { - emailVerification?: - | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) - | EmailVerificationRecipe; - passwordless?: - | ((fastify: FastifyInstance) => PasswordlessRecipeConfig) - | PasswordlessRecipe; - session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; - thirdPartyEmailPassword?: - | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) - | ThirdPartyEmailPasswordRecipe; - userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; -} +type SupertokensRecipeFactory = ( + fastify: FastifyInstance, +) => RecipeListFunction; interface SupertokensRecipes { emailVerification?: @@ -67,12 +56,4 @@ interface SupertokensThirdPartyProvider { google?: Parameters[0]; } -interface SupertokensThirdPartyProvider { - apple?: Parameters[0][]; - custom?: TypeProvider[]; - facebook?: Parameters[0]; - github?: Parameters[0]; - google?: Parameters[0]; -} - -export type { SupertokensConfig, SupertokensRecipes }; +export type { SupertokensConfig, SupertokensRecipeFactory, SupertokensRecipes }; diff --git a/packages/user/src/supertokens/types/passwordlessRecipe.ts b/packages/user/src/supertokens/types/passwordlessRecipe.ts deleted file mode 100644 index a2e2b2f9a..000000000 --- a/packages/user/src/supertokens/types/passwordlessRecipe.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { - APIInterface, - RecipeInterface, -} from "supertokens-node/recipe/passwordless/types"; - -import { FastifyInstance } from "fastify"; - -type APIInterfaceWrapper = { - [key in keyof APIInterface]?: ( - originalImplementation: APIInterface, - fastify: FastifyInstance, - ) => APIInterface[key]; -}; - -interface PasswordlessRecipe { - contactMethod?: "EMAIL" | "EMAIL_OR_PHONE" | "PHONE"; - flowType?: "USER_INPUT_CODE"; - override?: { - apis?: APIInterfaceWrapper; - functions?: RecipeInterfaceWrapper; - }; -} - -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 dbbab93f9..2c8914d28 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -1,5 +1,4 @@ import type { FastifyRequest } from "fastify"; -import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import ProfileFieldService from "src/model/profileFields/service"; @@ -19,13 +18,6 @@ interface EmailOptions { templateName?: string; } -type TwilioConfig = Omit< - TwilioServiceConfig, - "from" | "messagingServiceSid" -> & { - verifyServiceSid: string; -}; - interface UserConfig { email?: IsEmailOptions; emailOverrides?: { @@ -36,9 +28,6 @@ interface UserConfig { resetPasswordNotification?: EmailOptions; }; features?: { - passwordlessLogin?: { - enabled?: boolean; - }; profileFields?: { enabled?: boolean; }; @@ -105,13 +94,6 @@ interface UserConfig { ) => Promise; }; password?: StrongPasswordOptions; - passwordLessConfig?: { - bypassSmsFor?: string[]; - devModeOtp: string; - enableDevMode: boolean; - fallbackEmailDomain?: string; - twilio?: TwilioConfig; - }; permissions?: string[]; photoMaxSizeInMB?: number; role?: string; @@ -165,4 +147,4 @@ interface UserConfig { }; } -export type { EmailOptions, TwilioConfig, UserConfig }; +export type { EmailOptions, UserConfig }; diff --git a/packages/user/src/types/user.ts b/packages/user/src/types/user.ts index 0ab89ced8..2e7dcee5b 100644 --- a/packages/user/src/types/user.ts +++ b/packages/user/src/types/user.ts @@ -14,6 +14,7 @@ interface User { email: string; id: string; lastLoginAt: number; + phoneNumber?: string; photo?: Photo; photoId?: null | number; profile?: { [key: string]: boolean | null | number | string }; @@ -38,6 +39,7 @@ type UserUpdateInput = Partial< | "email" | "id" | "lastLoginAt" + | "phoneNumber" | "photo" | "profile" | "roles" From bd5a3e7b4928c1f9b46021f555b29f389ce2817c Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 28 Jul 2026 13:20:34 +0545 Subject: [PATCH 25/37] feat(passwordless): extract passwordless login into standalone package --- README.md | 1 + docs/specs/passwordless-package-extraction.md | 140 ++++++++ packages/passwordless/.gitignore | 4 + packages/passwordless/FEATURES.md | 69 ++++ packages/passwordless/GUIDE.md | 204 +++++++++++ packages/passwordless/README.md | 95 +++++ packages/passwordless/eslint.config.js | 11 + packages/passwordless/package.json | 68 ++++ .../passwordless/src/__test__/plugin.test.ts | 70 ++++ .../src/__test__/recipeConfig.spec.ts | 137 +++++++ packages/passwordless/src/constants.ts | 18 + packages/passwordless/src/index.ts | 18 + .../passwordless/src/lib/getTwilioClient.ts | 30 ++ packages/passwordless/src/plugin.ts | 20 ++ packages/passwordless/src/recipe/config.ts | 187 ++++++++++ .../passwordless/src/recipe/consumeCode.ts | 125 +++++++ .../src/recipe/consumeCodePost.ts | 115 ++++++ .../src/recipe/initPasswordlessRecipe.ts | 17 + packages/passwordless/src/types.ts | 78 ++++ packages/passwordless/tsconfig.json | 9 + packages/passwordless/vite.config.ts | 60 ++++ pnpm-lock.yaml | 339 +++++------------- 22 files changed, 1559 insertions(+), 256 deletions(-) create mode 100644 docs/specs/passwordless-package-extraction.md create mode 100644 packages/passwordless/.gitignore create mode 100644 packages/passwordless/FEATURES.md create mode 100644 packages/passwordless/GUIDE.md create mode 100644 packages/passwordless/README.md create mode 100644 packages/passwordless/eslint.config.js create mode 100644 packages/passwordless/package.json create mode 100644 packages/passwordless/src/__test__/plugin.test.ts create mode 100644 packages/passwordless/src/__test__/recipeConfig.spec.ts create mode 100644 packages/passwordless/src/constants.ts create mode 100644 packages/passwordless/src/index.ts create mode 100644 packages/passwordless/src/lib/getTwilioClient.ts create mode 100644 packages/passwordless/src/plugin.ts create mode 100644 packages/passwordless/src/recipe/config.ts create mode 100644 packages/passwordless/src/recipe/consumeCode.ts create mode 100644 packages/passwordless/src/recipe/consumeCodePost.ts create mode 100644 packages/passwordless/src/recipe/initPasswordlessRecipe.ts create mode 100644 packages/passwordless/src/types.ts create mode 100644 packages/passwordless/tsconfig.json create mode 100644 packages/passwordless/vite.config.ts diff --git a/README.md b/README.md index d93bea1d7..4ad0f62c3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A set of fastify libraries - @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) - @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) - @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) +- @prefabs.tech/fastify-passwordless (https://www.npmjs.com/package/@prefabs.tech/fastify-passwordless) - @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) - @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) - @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) diff --git a/docs/specs/passwordless-package-extraction.md b/docs/specs/passwordless-package-extraction.md new file mode 100644 index 000000000..2c68c6cbf --- /dev/null +++ b/docs/specs/passwordless-package-extraction.md @@ -0,0 +1,140 @@ +# Spec: Extract passwordless login into `@prefabs.tech/fastify-passwordless` + +Status: implemented on branch `feat/passwordless-verify-service` (2026-07-27). + +## 1. Problem statement + +Passwordless login (phone/SMS OTP via Twilio Verify) was built inside +`packages/user` with three disjoint config surfaces — +`user.features.passwordlessLogin.enabled`, `user.passwordLessConfig`, and +`user.supertokens.recipes.passwordless` — and pulled `twilio` into the runtime +`dependencies` of the auth package that every consumer installs. It is an +opt-in feature that a minority of apps use. + +## 2. The constraint that shapes the design + +**SuperTokens permits exactly one global `supertokens.init()`.** +`packages/user/src/supertokens/init.ts` calls it synchronously during plugin +registration, with `recipeList: getRecipeList(fastify)` fixed at that moment. A +plugin registered *after* `fastify-user` therefore cannot contribute a recipe — +there is no post-init recipe API. + +Two mechanisms were considered: + +1. **Registry + register-before-user (chosen).** `fastify-user` keeps `init()` + where it is. Recipe packages push a factory into a `fastify.supertokensRecipes` + decorator that `getRecipeList` drains. Wrong order throws. +2. **Registry + defer `init()` to `onReady`.** Order-independent. Rejected: it + changes init timing for every already-published `fastify-user` consumer, and + any consumer calling a SuperTokens API between `register` and `ready` would + break. + +Worth recording for whoever revisits option 2: it *is* technically viable. +`supertokens-node@14.1.4`'s Fastify plugin resolves the singleton only inside a +`preHandler` (`lib/build/framework/fastify/framework.js:199-212`), not at +registration time. `seedRoles` is already an `onReady` hook added after +`register(supertokensPlugin)`, so an init-in-`onReady` added earlier would still +sequence correctly. The blocker is consumer compatibility, not the SDK. + +## 3. Target design + +```typescript +// packages/user — new public API +addSupertokensRecipe(fastify, (fastify) => RecipeListFunction): void +``` + +- Throws when `fastify.hasDecorator("supertokensInitialized")` — i.e. when + called after `fastify-user` registered — with a message naming the fix. +- Lazily creates the `supertokensRecipes` decorator, so no ordering requirement + between multiple recipe packages. +- `init.ts` sets `supertokensInitialized` after `supertokens.init(...)`. +- Both plugins are `fastify-plugin`-wrapped, so decorators land on the same root + instance and encapsulation never enters the picture. + +Consumer order: + +```typescript +await fastify.register(passwordlessPlugin); // pushes the recipe factory +await fastify.register(userPlugin); // init() drains the registry +``` + +The new package collapses the three config surfaces into one +`config.passwordless` namespace and owns `twilio`. + +## 4. Why this was safe to do non-additively + +All passwordless code was branch-local. Verified before designing: + +```bash +git show main:packages/user/src/types/config.ts | grep -i twilio # no match +git show main:packages/user/src/supertokens/types/index.ts | grep -i passwordless +git grep -il passwordless main -- packages/ # empty +``` + +Nothing was published, so removing `passwordLessConfig`, +`features.passwordlessLogin`, `TwilioConfig` and `SupertokensRecipes.passwordless` +from `UserConfig` is additive from an npm consumer's point of view and did not +trip CLAUDE.md escalation item 1. **Run this check before any "clean removal" +claim** — it is the difference between a refactor and a breaking change. + +## 5. Bug found and fixed in passing + +Passwordless signup was broken at runtime. `consumeCode` inserted `phoneNumber`, +`DefaultSqlFactory` decamelized it to a `phone_number` column that did not +exist, and an `as UserCreateInput` cast was what let it compile. Fixed in +`packages/user` (it owns the users table): + +- `phoneNumber?: string` on `User`, omitted from `UserUpdateInput`; +- `phoneNumber` added to the **runtime** denylist in `filterUserUpdateInput` — + the `Omit` in the type is not enforcement, and every other immutable field is + on that list; +- additive idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number`, + mirroring the existing `addProfileInUsersTableQuery`; +- the cast deleted. + +**Rule extracted:** an `as SomeInput` cast on a `BaseService.create` argument is +a smell, not a convenience — `getCreateSql` decamelizes *every* key into a +column name, so the cast converts a compile error into a runtime +undefined-column error. + +`UserSqlFactory` inherits `_validationSchema = z.any()` from `DefaultSqlFactory`, +so no zod schema needed widening. Check this before assuming a new column needs +a schema change. + +## 6. Gotchas paid for during implementation + +1. **Vite `external` does not match subpaths.** `Object.keys(peerDependencies)` + externalizes the bare specifier only, so `supertokens-node/recipe/passwordless` + was bundled: the first passwordless build was **1.1 MB** and transformed 302 + modules. `packages/user/vite.config.ts` already carried the fix — + `/supertokens-node+/` in the `external` array. Adding it dropped the bundle to + 6 kB / 9 modules. Any new package importing `supertokens-node` subpaths needs + that regex. A suspiciously large `dist/` is the symptom. + +2. **`expect(mockFn).toHaveBeenCalledWith(fastifyInstance)` throws.** Vitest + deep-equals the argument, which touches Fastify getters that fail before the + server is listening (`TypeError: Cannot read properties of undefined (reading + 'family')`, `fastify.js:296`). Use an identity check on + `mockFn.mock.calls[0][0]` instead. + +3. **`supertokens.init()` is a process-global singleton, so tests must not go + through `register(userPlugin)` twice.** The second registration throws + "already initialised", which makes an ordering test pass for the wrong + reason. Test `addSupertokensRecipe` and `getRecipeList` directly against a + real-but-unregistered Fastify instance decorated with `config`, and stub the + individual recipe inits. + +4. **`pnpm -r install` aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`** + in a non-interactive shell when the workspace layout changed. `CI=true` plus + `--no-frozen-lockfile` is the fix for the run that introduces a new package. + +5. **`unicorn/no-unreadable-for-of-expression`** rejects + `for (const x of a ?? [])`. Hoist the fallback into a `const` first. + +## 7. Not verified + +An end-to-end dev-mode signup against a live SuperTokens core was not run — +there is no consumer app in this repo. What *was* verified for the +`phone_number` fix: the rendered migration SQL (default and overridden table +name), its idempotency under `pg-mem` across two applications, and the removal +of the cast under `tsc`. diff --git a/packages/passwordless/.gitignore b/packages/passwordless/.gitignore new file mode 100644 index 000000000..62853f374 --- /dev/null +++ b/packages/passwordless/.gitignore @@ -0,0 +1,4 @@ +**/*.log* +/coverage +/dist +/node_modules diff --git a/packages/passwordless/FEATURES.md b/packages/passwordless/FEATURES.md new file mode 100644 index 000000000..dfc3b2bff --- /dev/null +++ b/packages/passwordless/FEATURES.md @@ -0,0 +1,69 @@ + + +# @prefabs.tech/fastify-passwordless — Features + +## Plugin Lifecycle + +1. **Enable/disable via config flag** — when `config.passwordless.enabled === false`, no recipe factory is contributed and the SuperTokens passwordless endpoints are not served. The check is `=== false`; `undefined` means enabled. + +2. **Automatic recipe registration** — on registration (when enabled), the plugin pushes `initPasswordlessRecipe` into the SuperTokens recipe registry via `addSupertokensRecipe` from `@prefabs.tech/fastify-user`. No consumer wiring beyond registering the plugin is required. + +3. **Registration order guard** — `addSupertokensRecipe` throws when the Fastify instance already carries the `supertokensInitialized` decorator, i.e. when this plugin is registered *after* `@prefabs.tech/fastify-user`. SuperTokens allows exactly one global `init()`, so a late registration could not contribute a recipe; failing loudly beats silently dropping passwordless login. + +4. **No routes of its own** — this package registers no controllers. The passwordless endpoints are served by the SuperTokens Fastify plugin that `@prefabs.tech/fastify-user` registers. + +## Recipe Configuration + +5. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.passwordless`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. + +6. **Full recipe escape hatch** — when `config.passwordless.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. + +7. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.passwordless` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. + +8. **API override wrappers** — each entry in `config.passwordless.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. + +9. **Function override wrappers** — same mechanism for `config.passwordless.override.functions` over the built-in `consumeCode` override. + +## Twilio Verify Integration + +10. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP. + +11. **Dev mode OTP** — when `config.passwordless.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. + +12. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.passwordless.bypassSmsFor` also get `devModeOtp` and no SMS is sent. + +13. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown. + +14. **Dev mode skips SMS delivery entirely** — in dev mode the recipe supplies `createAndSendCustomTextMessage` (a log line) instead of `smsDelivery`. + +15. **Phone number capture on create** — the `createCodePOST` override copies `input.phoneNumber` onto `input.userContext` so downstream hooks can read it. + +16. **OTP verification on consume** — the `consumeCodePOST` override looks the device up by `preAuthSessionId`, then calls `verify.v2.services(verifyServiceSid).verificationChecks.create({ code, to })`. On `approved` it replays the original `consumeCodePOST` with the placeholder code; otherwise it returns `INCORRECT_USER_INPUT_CODE_ERROR`. + +17. **Graceful degradation to RESTART_FLOW_ERROR** — a missing device/phone number, unusable Twilio credentials, or a thrown Twilio Verify call all return `{ status: "RESTART_FLOW_ERROR" }` after logging. + +18. **Dev mode and bypassed numbers skip Twilio on consume** — they go straight to the original `consumeCodePOST`, which validates against `devModeOtp`. + +19. **Magic link flows pass through untouched** — when `input` carries no `userInputCode`, `consumeCodePOST` delegates to the original implementation without contacting Twilio. + +20. **Synthetic email enrichment** — successful consume responses get `email` filled in as `@` when SuperTokens has none. + +## Local User Creation + +21. **Role existence check before signup** — `functions.consumeCode` verifies every role in `userContext.roles` (default `[config.user.role ?? ROLE_USER]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise. + +22. **Local user row on first sign-in** — when SuperTokens reports `createdNewUser`, a row is created through `getUserService` with the id, phone number, and synthetic email. The email domain falls back to the app name lowercased with whitespace stripped plus `.com`. + +23. **Rollback on failed insert** — if the local insert throws, the SuperTokens user is deleted via `deleteUser` before the error is rethrown, so the two stores cannot drift. + +24. **Missing phone number aborts signup** — when neither a phone number nor an email is available the SuperTokens user is deleted and an error is thrown. + +25. **Role assignment** — each role is assigned with `UserRoles.addRoleToUser`; a non-`OK` status is logged rather than thrown. + +26. **`lastLoginAt` refresh on returning users** — when no new user was created, `lastLoginAt` is updated; a failure is logged and swallowed so sign-in still succeeds. + +27. **Multi-tenant request context** — the user service is built from the request recovered via `getRequestFromUserContext`, so `request.config`, `request.slonik` and `request.dbSchema` win over the Fastify-level ones when present. + +## Known Limitations + +28. **`bypassSmsFor` does not apply on resend** — `resendCodePOST` is not overridden and `userContext.phoneNumber` is only set by `createCodePOST`, so `getCustomUserInputCode` cannot match a bypassed number on the resend path. diff --git a/packages/passwordless/GUIDE.md b/packages/passwordless/GUIDE.md new file mode 100644 index 000000000..fd8ea8dac --- /dev/null +++ b/packages/passwordless/GUIDE.md @@ -0,0 +1,204 @@ +# @prefabs.tech/fastify-passwordless — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-passwordless +``` + +```bash +pnpm add @prefabs.tech/fastify-passwordless +``` + +Peer dependencies are listed in [README.md](./README.md#requirements). + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-passwordless test +pnpm --filter @prefabs.tech/fastify-passwordless build +``` + +## Registration order — read this first + +SuperTokens permits exactly one global `supertokens.init()`. `@prefabs.tech/fastify-user` performs it synchronously while it is being registered, building its recipe list at that moment. Recipe plugins therefore contribute their recipe through a registry that `@prefabs.tech/fastify-user` drains at init time, which means **this plugin must be registered before it**. + +```typescript +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin); +await fastify.register(passwordlessPlugin); // pushes the recipe factory +await fastify.register(userPlugin); // supertokens.init() drains the registry +``` + +Get the order wrong and registration fails loudly rather than silently dropping passwordless login: + +``` +Error: SuperTokens is already initialised. Register SuperTokens recipe plugins +before @prefabs.tech/fastify-user. +``` + +The registry itself is `addSupertokensRecipe`, exported from `@prefabs.tech/fastify-user`. It is generic — any package can use it to contribute a SuperTokens recipe. + +## Setup + +```typescript +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import configPlugin from "@prefabs.tech/fastify-config"; +import passwordlessPlugin from "@prefabs.tech/fastify-passwordless"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import userPlugin from "@prefabs.tech/fastify-user"; +import Fastify from "fastify"; + +const config: ApiConfig = { + // ...the rest of your app config + passwordless: { + fallbackEmailDomain: "example.com", + twilio: { + accountSid: process.env.TWILIO_ACCOUNT_SID as string, + authToken: process.env.TWILIO_AUTH_TOKEN as string, + verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID as string, + }, + }, +}; + +const fastify = Fastify(); + +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin); +await fastify.register(passwordlessPlugin); +await fastify.register(userPlugin); +``` + +All subsequent examples assume this setup. + +--- + +## Base Libraries + +### `supertokens-node` — Passwordless recipe (MODIFIED passthrough) + +This plugin does not expose routes of its own. It configures SuperTokens' Passwordless recipe, and the SuperTokens Fastify plugin registered by `@prefabs.tech/fastify-user` serves the resulting endpoints (`POST /signinup/code`, `POST /signinup/code/consume`, `POST /signinup/code/resend`). See the [SuperTokens Passwordless docs](https://supertokens.com/docs/passwordless/introduction) for the endpoint contracts. + +Our delta over the stock recipe: + +- `contactMethod` is constrained to `"EMAIL" | "EMAIL_OR_PHONE" | "PHONE"` and defaults to `"PHONE"`. +- `flowType` is constrained to `"USER_INPUT_CODE"` — magic-link and link-or-code flows are deliberately not supported. +- `getCustomUserInputCode` returns a placeholder rather than a real OTP (see below). +- `apis.consumeCodePOST`, `apis.createCodePOST` and `functions.consumeCode` are overridden. `resendCodePOST` and `functions.createCode` are not. +- `smsDelivery.sendSms` is replaced with a Twilio Verify call, or with a log line in dev mode. + +### `twilio` — Verify API (PARTIAL passthrough) + +Only the Verify v2 service is used: `verifications.create` to send an OTP and `verificationChecks.create` to check one. Messaging/SMS APIs are not used, which is why `TwilioConfig` omits `from` and `messagingServiceSid` and requires `verifyServiceSid` instead. + +--- + +## How the Twilio Verify bridge works + +SuperTokens insists on owning a user input code; Twilio Verify insists on owning the OTP. The two are reconciled like this: + +1. Sign in/up hits `createCodePOST`. The override records the phone number on `userContext`, then the SMS-delivery override asks Twilio Verify to send an OTP. +2. SuperTokens still stores a code of its own, so `getCustomUserInputCode` hands it the constant `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) instead of the real OTP. +3. The user submits the OTP they received. `consumeCodePOST` looks the device up by `preAuthSessionId` to recover the phone number, then checks the submitted code against Twilio Verify. If Twilio approves, the original `consumeCodePOST` is replayed with the placeholder so SuperTokens can complete its own flow. +4. `functions.consumeCode` then creates the matching row in your `users` table. + +## User creation + +On first successful sign-in, `functions.consumeCode`: + +- Verifies every role in `userContext.roles` (default `[config.user.role ?? "USER"]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise. +- Creates the local user with the phone number and a synthetic email of `@`, falling back to `.com` when `fallbackEmailDomain` is unset. SuperTokens requires an email; passwordless phone users do not supply one. +- Assigns the roles via `UserRoles.addRoleToUser`. +- Deletes the SuperTokens user again if the local insert fails, so the two stores cannot drift. + +On subsequent sign-ins it only updates `lastLoginAt`. + +The `phone_number` column and the `phoneNumber` field on `User` are provided by `@prefabs.tech/fastify-user`; its migrations add the column automatically. + +## Configuration reference + +`config.passwordless`: + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `true` | Only `false` disables; `undefined` means enabled. | +| `contactMethod` | `"EMAIL" \| "EMAIL_OR_PHONE" \| "PHONE"` | `"PHONE"` | | +| `flowType` | `"USER_INPUT_CODE"` | `"USER_INPUT_CODE"` | | +| `fallbackEmailDomain` | `string` | app name + `.com` | Domain of the synthetic email. | +| `enableDevMode` | `boolean` | `false` | Skips Twilio for every number. | +| `devModeOtp` | `string` | — | Required when `enableDevMode` is `true`. | +| `bypassSmsFor` | `string[]` | `[]` | Phone numbers that skip Twilio and accept `devModeOtp`. | +| `twilio` | `TwilioConfig` | — | Required unless `enableDevMode` is `true`. | +| `override` | `{ apis?, functions? }` | — | Per-API/per-function wrappers, applied after the built-in overrides. | +| `recipe` | `(fastify) => TypeInput` | — | Full escape hatch: replaces the generated recipe config entirely. | + +`TwilioConfig` is SuperTokens' `TwilioServiceConfig` without `from` and `messagingServiceSid`, plus a required `verifyServiceSid`. + +### Disabling the plugin + +```typescript +passwordless: { + enabled: false; +} +``` + +No recipe is contributed and the SuperTokens passwordless endpoints are not served. + +### Development without Twilio + +```typescript +passwordless: { + devModeOtp: "123456", + enableDevMode: true, + fallbackEmailDomain: "example.com", +} +``` + +Every number accepts `123456` and no SMS is sent. To keep Twilio live for real users but bypass it for a handful of test numbers, leave `enableDevMode` off and use `bypassSmsFor` together with `devModeOtp`. + +## Overriding behaviour + +Wrappers receive the original implementation and the Fastify instance, and are applied **after** the built-in overrides — so replacing `consumeCodePOST` or `consumeCode` removes the Twilio Verify integration or the local user creation respectively. + +```typescript +passwordless: { + override: { + apis: { + consumeCodePOST: (originalImplementation, fastify) => async (input) => { + fastify.log.info("consuming a passwordless code"); + + return originalImplementation.consumeCodePOST!(input); + }, + }, + }, +} +``` + +For total control, bypass the generated config entirely: + +```typescript +passwordless: { + recipe: (fastify) => ({ + contactMethod: "PHONE", + flowType: "USER_INPUT_CODE", + }), +} +``` + +## Validation and failure modes + +`getPasswordlessRecipeConfig` runs during `supertokens.init()`, so configuration mistakes fail at boot rather than on the first sign-in attempt: + +- No `config.passwordless` at all → `Passwordless recipe config is missing.` +- `enableDevMode: true` without `devModeOtp` → `passwordless.devModeOtp is required when passwordless.enableDevMode is true` +- Not in dev mode and `twilio` missing or incomplete → `Twilio config is missing for the passwordless recipe.` / `accountSid and ... authToken are required` + +At request time, a Twilio Verify failure is logged and returned as `RESTART_FLOW_ERROR`; a rejected code returns `INCORRECT_USER_INPUT_CODE_ERROR`. + +## Known limitation + +`userContext.phoneNumber` is only set by the `createCodePOST` override, so it is unset on the **resend** path (`resendCodePOST` is not overridden). The `bypassSmsFor` check inside `getCustomUserInputCode` therefore cannot match on a resend. diff --git a/packages/passwordless/README.md b/packages/passwordless/README.md new file mode 100644 index 000000000..f8eb8db2b --- /dev/null +++ b/packages/passwordless/README.md @@ -0,0 +1,95 @@ +# @prefabs.tech/fastify-passwordless + +A [Fastify](https://github.com/fastify/fastify) plugin that adds phone/SMS OTP passwordless login to an API built on [@prefabs.tech/fastify-user](../user/), backed by the [Twilio Verify](https://www.twilio.com/docs/verify) API. + +## Why this plugin? + +SuperTokens ships a Passwordless recipe, but wiring it to Twilio Verify and to your own `users` table is a surprising amount of work — SuperTokens wants to own the OTP, Twilio Verify wants to own the OTP, and neither knows about your database. This plugin exists to: + +- **Bridge SuperTokens and Twilio Verify**: Twilio Verify generates, delivers and checks the real OTP; SuperTokens is handed a placeholder code so its own flow still completes. All of that is hidden behind one plugin registration. +- **Keep the auth package lean**: passwordless is opt-in. Apps that do not use it never install `twilio`, and `@prefabs.tech/fastify-user` carries no passwordless config surface. +- **Initialise the recipe automatically**: registering this plugin is all it takes — the SuperTokens Passwordless recipe is contributed to `@prefabs.tech/fastify-user`'s recipe list for you. +- **Create the local user row**: on first sign-in a matching row is created in your `users` table with the phone number and a synthetic `@` email, since SuperTokens requires an email. +- **Support local development without Twilio**: a dev mode and a per-number bypass list accept a fixed OTP so you can develop and test without sending real SMS. + +## Requirements + +Peer dependencies (install compatible versions — see [package.json](./package.json)): + +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-error-handler](../error-handler/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [@prefabs.tech/fastify-user](../user/) +- [`fastify`](https://www.npmjs.com/package/fastify) +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) +- [`slonik`](https://www.npmjs.com/package/slonik) +- [`supertokens-node`](https://www.npmjs.com/package/supertokens-node) + +## Installation + +Install with npm: + +```bash +npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user @prefabs.tech/fastify-passwordless fastify fastify-plugin slonik supertokens-node +``` + +Install with pnpm: + +```bash +pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user @prefabs.tech/fastify-passwordless fastify fastify-plugin slonik supertokens-node +``` + +## Usage + +### Register the plugin — before `@prefabs.tech/fastify-user` + +SuperTokens allows exactly one global `init()`, and `@prefabs.tech/fastify-user` performs it while it is being registered. This plugin therefore has to be registered **first**, so its recipe is in the list by the time that happens. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import passwordlessPlugin from "@prefabs.tech/fastify-passwordless"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import userPlugin from "@prefabs.tech/fastify-user"; +import Fastify from "fastify"; + +const fastify = Fastify(); + +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin); +await fastify.register(passwordlessPlugin); // contributes the recipe +await fastify.register(userPlugin); // runs supertokens.init() +``` + +Registering it after `@prefabs.tech/fastify-user` throws: + +``` +SuperTokens is already initialised. Register SuperTokens recipe plugins before @prefabs.tech/fastify-user. +``` + +### Configuration + +```typescript +const config: ApiConfig = { + // ... + passwordless: { + fallbackEmailDomain: "example.com", + twilio: { + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID, + }, + }, +}; +``` + +For local development, skip Twilio entirely: + +```typescript +passwordless: { + devModeOtp: "123456", + enableDevMode: true, + fallbackEmailDomain: "example.com", +} +``` + +See the [developer guide](./GUIDE.md) for the full configuration reference, the SuperTokens endpoints this exposes, and the override hooks. diff --git a/packages/passwordless/eslint.config.js b/packages/passwordless/eslint.config.js new file mode 100644 index 000000000..d95745548 --- /dev/null +++ b/packages/passwordless/eslint.config.js @@ -0,0 +1,11 @@ +import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; + +export default [ + ...fastifyConfig, + { + files: ["**/__test__/**"], + rules: { + "unicorn/filename-case": "off", + }, + }, +]; diff --git a/packages/passwordless/package.json b/packages/passwordless/package.json new file mode 100644 index 000000000..057e4c3cd --- /dev/null +++ b/packages/passwordless/package.json @@ -0,0 +1,68 @@ +{ + "name": "@prefabs.tech/fastify-passwordless", + "version": "0.94.1", + "description": "Fastify passwordless plugin", + "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/passwordless#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/prefabs-tech/fastify.git", + "directory": "packages/passwordless" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./dist/prefabs-tech-fastify-passwordless.js", + "require": "./dist/prefabs-tech-fastify-passwordless.cjs" + } + }, + "main": "./dist/prefabs-tech-fastify-passwordless.cjs", + "module": "./dist/prefabs-tech-fastify-passwordless.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "sort-package": "npx sort-package-json", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit -p tsconfig.json --composite false" + }, + "dependencies": { + "twilio": "6.0.0" + }, + "devDependencies": { + "@prefabs.tech/eslint-config": "0.8.7", + "@prefabs.tech/fastify-config": "0.94.1", + "@prefabs.tech/fastify-error-handler": "0.94.1", + "@prefabs.tech/fastify-slonik": "0.94.1", + "@prefabs.tech/fastify-user": "0.94.1", + "@prefabs.tech/tsconfig": "0.8.7", + "@types/node": "24.13.3", + "@vitest/coverage-istanbul": "3.2.7", + "eslint": "10.7.0", + "fastify": "5.10.0", + "fastify-plugin": "6.0.0", + "prettier": "3.9.5", + "slonik": "46.8.0", + "supertokens-node": "14.1.4", + "typescript": "5.9.3", + "vite": "8.1.5", + "vitest": "3.2.7" + }, + "peerDependencies": { + "@prefabs.tech/fastify-config": "0.94.1", + "@prefabs.tech/fastify-error-handler": "0.94.1", + "@prefabs.tech/fastify-slonik": "0.94.1", + "@prefabs.tech/fastify-user": "0.94.1", + "fastify": ">=5.10.0", + "fastify-plugin": ">=5.1.0", + "slonik": ">=46.8.0", + "supertokens-node": ">=14.1.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/passwordless/src/__test__/plugin.test.ts b/packages/passwordless/src/__test__/plugin.test.ts new file mode 100644 index 000000000..9ab2fc090 --- /dev/null +++ b/packages/passwordless/src/__test__/plugin.test.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +import plugin from "../plugin"; + +/** + * Builds a Fastify instance decorated with everything the passwordless plugin + * reads. `addSupertokensRecipe` comes from @prefabs.tech/fastify-user and only + * touches decorators, so no SuperTokens init happens here. + */ +const buildFastify = ( + passwordlessConfig?: Record, +): FastifyInstance => { + const fastify = Fastify({ logger: false }); + + fastify.decorate("config", { + appName: "Test App", + passwordless: passwordlessConfig, + }); + + return fastify; +}; + +describe("passwordlessPlugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("registers the recipe factory when enabled is undefined", async () => { + fastify = buildFastify({}); + await fastify.register(plugin); + + expect(fastify.supertokensRecipes).toHaveLength(1); + }); + + it("registers the recipe factory when enabled is true", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + + expect(fastify.supertokensRecipes).toHaveLength(1); + }); + + it("registers the recipe factory when the passwordless config is absent", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + + expect(fastify.supertokensRecipes).toHaveLength(1); + }); + + it("registers no recipe factory when enabled === false", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + + expect(fastify.supertokensRecipes).toBeUndefined(); + }); + + it("throws when registered after SuperTokens has already been initialised", async () => { + fastify = buildFastify({}); + fastify.decorate("supertokensInitialized", true); + + await expect(fastify.register(plugin)).rejects.toThrow( + /Register SuperTokens recipe plugins before @prefabs.tech\/fastify-user/, + ); + }); +}); diff --git a/packages/passwordless/src/__test__/recipeConfig.spec.ts b/packages/passwordless/src/__test__/recipeConfig.spec.ts new file mode 100644 index 000000000..486801037 --- /dev/null +++ b/packages/passwordless/src/__test__/recipeConfig.spec.ts @@ -0,0 +1,137 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_CONTACT_METHOD, + DEFAULT_FLOW_TYPE, + TWILIO_VERIFY_PLACEHOLDER_CODE, +} from "../constants"; + +const twilioClientMock = { + client: { verify: { v2: { services: vi.fn() } } }, + verifyServiceSid: "VA123", +}; + +// getTwilioClient is our own module and is the only thing here that reaches an +// external service, so it is the mock seam. +vi.mock("../lib/getTwilioClient", () => ({ + default: vi.fn(() => twilioClientMock), +})); + +const { default: getPasswordlessRecipeConfig } = + await import("../recipe/config"); + +const twilio = { + accountSid: "AC123", + authToken: "token", + verifyServiceSid: "VA123", +}; + +const buildFastify = ( + passwordlessConfig?: Record, +): FastifyInstance => { + const fastify = Fastify({ logger: false }); + + fastify.decorate("config", { + appName: "Test App", + passwordless: passwordlessConfig, + }); + + return fastify; +}; + +describe("getPasswordlessRecipeConfig", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("defaults contactMethod and flowType", () => { + fastify = buildFastify({ twilio }); + + const config = getPasswordlessRecipeConfig(fastify); + + expect(config.contactMethod).toBe(DEFAULT_CONTACT_METHOD); + expect(config.flowType).toBe(DEFAULT_FLOW_TYPE); + }); + + it("honours a configured contactMethod", () => { + fastify = buildFastify({ contactMethod: "EMAIL_OR_PHONE", twilio }); + + expect(getPasswordlessRecipeConfig(fastify).contactMethod).toBe( + "EMAIL_OR_PHONE", + ); + }); + + it("throws when the passwordless config is missing", () => { + fastify = buildFastify(); + + expect(() => getPasswordlessRecipeConfig(fastify)).toThrow( + /Passwordless recipe config is missing/, + ); + }); + + it("throws when dev mode is on without a devModeOtp", () => { + fastify = buildFastify({ enableDevMode: true }); + + expect(() => getPasswordlessRecipeConfig(fastify)).toThrow( + /devModeOtp is required/, + ); + }); + + it("returns the dev mode OTP for every number in dev mode", async () => { + fastify = buildFastify({ devModeOtp: "123456", enableDevMode: true }); + + const { getCustomUserInputCode } = getPasswordlessRecipeConfig(fastify); + + await expect( + getCustomUserInputCode!({ phoneNumber: "+15550001111" }), + ).resolves.toBe("123456"); + }); + + it("returns the dev mode OTP for a bypassed number outside dev mode", async () => { + fastify = buildFastify({ + bypassSmsFor: ["+15550001111"], + devModeOtp: "123456", + twilio, + }); + + const { getCustomUserInputCode } = getPasswordlessRecipeConfig(fastify); + + await expect( + getCustomUserInputCode!({ phoneNumber: "+15550001111" }), + ).resolves.toBe("123456"); + }); + + it("returns the Twilio Verify placeholder for a regular number", async () => { + fastify = buildFastify({ bypassSmsFor: ["+15550001111"], twilio }); + + const { getCustomUserInputCode } = getPasswordlessRecipeConfig(fastify); + + await expect( + getCustomUserInputCode!({ phoneNumber: "+15559998888" }), + ).resolves.toBe(TWILIO_VERIFY_PLACEHOLDER_CODE); + }); + + it("swaps SMS delivery for a log line in dev mode", () => { + fastify = buildFastify({ devModeOtp: "123456", enableDevMode: true }); + + const config = getPasswordlessRecipeConfig(fastify); + + expect(config.createAndSendCustomTextMessage).toBeDefined(); + expect(config.smsDelivery).toBeUndefined(); + }); + + it("uses Twilio SMS delivery outside dev mode", () => { + fastify = buildFastify({ twilio }); + + const config = getPasswordlessRecipeConfig(fastify); + + expect(config.smsDelivery).toBeDefined(); + expect(config.createAndSendCustomTextMessage).toBeUndefined(); + }); +}); diff --git a/packages/passwordless/src/constants.ts b/packages/passwordless/src/constants.ts new file mode 100644 index 000000000..5bbf070e6 --- /dev/null +++ b/packages/passwordless/src/constants.ts @@ -0,0 +1,18 @@ +const DEFAULT_CONTACT_METHOD = "PHONE"; +const DEFAULT_FLOW_TYPE = "USER_INPUT_CODE"; + +const ERROR_CODES = { + SIGNUP_FAILED_ERROR: "SIGNUP_FAILED_ERROR", +}; + +// SuperTokens insists on storing a user input code of its own. When Twilio +// Verify owns the real OTP we hand SuperTokens this placeholder instead, and +// replay it once Twilio has approved the code the user actually typed. +const TWILIO_VERIFY_PLACEHOLDER_CODE = "000000"; + +export { + DEFAULT_CONTACT_METHOD, + DEFAULT_FLOW_TYPE, + ERROR_CODES, + TWILIO_VERIFY_PLACEHOLDER_CODE, +}; diff --git a/packages/passwordless/src/index.ts b/packages/passwordless/src/index.ts new file mode 100644 index 000000000..b8ebb8c8c --- /dev/null +++ b/packages/passwordless/src/index.ts @@ -0,0 +1,18 @@ +import type { PasswordlessConfig } from "./types"; + +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + passwordless?: PasswordlessConfig; + } +} + +export * from "./constants"; + +export { default as getTwilioClient } from "./lib/getTwilioClient"; +export { default } from "./plugin"; +export { default as getPasswordlessRecipeConfig } from "./recipe/config"; +export { default as consumeCode } from "./recipe/consumeCode"; +export { default as consumeCodePOST } from "./recipe/consumeCodePost"; +export { default as initPasswordlessRecipe } from "./recipe/initPasswordlessRecipe"; + +export type * from "./types"; diff --git a/packages/passwordless/src/lib/getTwilioClient.ts b/packages/passwordless/src/lib/getTwilioClient.ts new file mode 100644 index 000000000..4f130a326 --- /dev/null +++ b/packages/passwordless/src/lib/getTwilioClient.ts @@ -0,0 +1,30 @@ +import twilio from "twilio"; + +import type { TwilioConfig } from "../types"; + +const getTwilioClient = (config: TwilioConfig | undefined) => { + if (!config) { + throw new Error( + "Twilio config is missing for the passwordless recipe. Add `passwordless.twilio` to your app config.", + ); + } + + if (!config.verifyServiceSid) { + throw new Error( + "passwordless.twilio.verifyServiceSid is required for passwordless verification", + ); + } + + if (!config.accountSid || !config.authToken) { + throw new Error( + "passwordless.twilio.accountSid and passwordless.twilio.authToken are required for passwordless verification", + ); + } + + return { + client: twilio(config.accountSid, config.authToken), + verifyServiceSid: config.verifyServiceSid, + }; +}; + +export default getTwilioClient; diff --git a/packages/passwordless/src/plugin.ts b/packages/passwordless/src/plugin.ts new file mode 100644 index 000000000..1a7bd16c1 --- /dev/null +++ b/packages/passwordless/src/plugin.ts @@ -0,0 +1,20 @@ +import type { FastifyPluginAsync } from "fastify"; + +import { addSupertokensRecipe } from "@prefabs.tech/fastify-user"; +import FastifyPlugin from "fastify-plugin"; + +import initPasswordlessRecipe from "./recipe/initPasswordlessRecipe"; + +const passwordlessPlugin: FastifyPluginAsync = async (fastify) => { + if (fastify.config.passwordless?.enabled === false) { + fastify.log.info("fastify-passwordless plugin is not enabled"); + + return; + } + + fastify.log.info("Registering fastify-passwordless plugin"); + + addSupertokensRecipe(fastify, initPasswordlessRecipe); +}; + +export default FastifyPlugin(passwordlessPlugin); diff --git a/packages/passwordless/src/recipe/config.ts b/packages/passwordless/src/recipe/config.ts new file mode 100644 index 000000000..fad4bcee3 --- /dev/null +++ b/packages/passwordless/src/recipe/config.ts @@ -0,0 +1,187 @@ +import type { FastifyInstance } from "fastify"; +import type { + APIInterface, + TypeInput as PasswordlessRecipeConfig, + RecipeInterface, +} from "supertokens-node/recipe/passwordless/types"; + +import type { PasswordlessConfig } from "../types"; + +import { + DEFAULT_CONTACT_METHOD, + DEFAULT_FLOW_TYPE, + TWILIO_VERIFY_PLACEHOLDER_CODE, +} from "../constants"; +import getTwilioClient from "../lib/getTwilioClient"; +import consumeCode from "./consumeCode"; +import consumeCodePOST from "./consumeCodePost"; + +// SuperTokens has no first-class support for the Twilio Verify API, so both +// consumeCodePOST and consumeCode are overridden to bridge the two. +// +// How it works: +// 1. Sign in/up hits createCodePOST, which asks Twilio Verify to send an OTP to +// the phone number. +// 2. SuperTokens still requires a user input code of its own, so it stores +// TWILIO_VERIFY_PLACEHOLDER_CODE instead of the real OTP. +// 3. On consumeCodePOST the submitted OTP is checked against Twilio Verify. If +// Twilio approves, the original consumeCodePOST is replayed with +// TWILIO_VERIFY_PLACEHOLDER_CODE so SuperTokens can complete its own flow. +// 4. consumeCode then creates the matching row in our database, with a +// synthetic `@` email because SuperTokens +// requires an email field. + +const getPasswordlessRecipeConfig = ( + fastify: FastifyInstance, +): PasswordlessRecipeConfig => { + const passwordless: PasswordlessConfig | undefined = + fastify.config.passwordless; + + if (!passwordless) { + throw new Error( + "Passwordless recipe config is missing. Add `passwordless` to your app config.", + ); + } + + const isDevelopment = passwordless.enableDevMode === true; + const developmentModeOtp = passwordless.devModeOtp; + + if (isDevelopment && !developmentModeOtp) { + throw new Error( + "passwordless.devModeOtp is required when passwordless.enableDevMode is true", + ); + } + + const isDevelopmentNumber = (phoneNumber: string) => { + return (passwordless.bypassSmsFor || []).includes(phoneNumber); + }; + + // Fail at boot rather than on the first sign-in attempt. + if (!isDevelopment) { + getTwilioClient(passwordless.twilio); + } + + return { + contactMethod: passwordless.contactMethod || DEFAULT_CONTACT_METHOD, + flowType: passwordless.flowType || DEFAULT_FLOW_TYPE, + getCustomUserInputCode: async (userContext) => { + const phoneNumber = userContext?.phoneNumber as string | undefined; + + if (isDevelopment || (phoneNumber && isDevelopmentNumber(phoneNumber))) { + return developmentModeOtp as string; + } + + return TWILIO_VERIFY_PLACEHOLDER_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, + consumeCodePOST: consumeCodePOST(originalImplementation, fastify), + createCodePOST: async (input) => { + if ("phoneNumber" in input) { + input.userContext.phoneNumber = input.phoneNumber; + } + + return originalImplementation.createCodePOST!(input); + }, + ...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, + }; + }, + }, + ...(isDevelopment + ? { + createAndSendCustomTextMessage: async () => { + fastify.log.info( + `Skipping passwordless SMS delivery in development environment. Use default OTP [${developmentModeOtp}] for testing.`, + ); + }, + } + : { + smsDelivery: { + override: (originalImplementation) => { + return { + ...originalImplementation, + sendSms: async (input: { phoneNumber: string }) => { + if (isDevelopmentNumber(input.phoneNumber)) { + fastify.log.info( + `Skipping SMS for test number ${input.phoneNumber}.`, + ); + + return; + } + + const { client, verifyServiceSid } = getTwilioClient( + passwordless.twilio, + ); + + try { + await client.verify.v2 + .services(verifyServiceSid) + .verifications.create({ + channel: "sms", + to: input.phoneNumber, + }); + } catch (error) { + fastify.log.error( + error, + "Twilio Verify failed to send OTP", + ); + throw error; + } + }, + }; + }, + }, + }), + }; +}; + +export default getPasswordlessRecipeConfig; diff --git a/packages/passwordless/src/recipe/consumeCode.ts b/packages/passwordless/src/recipe/consumeCode.ts new file mode 100644 index 000000000..74d0b68bb --- /dev/null +++ b/packages/passwordless/src/recipe/consumeCode.ts @@ -0,0 +1,125 @@ +import type { User } from "@prefabs.tech/fastify-user"; +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 { + areRolesExist, + getUserService, + ROLE_USER, +} from "@prefabs.tech/fastify-user"; +import { deleteUser, getRequestFromUserContext } from "supertokens-node"; +import UserRoles from "supertokens-node/recipe/userroles"; + +import { ERROR_CODES } from "../constants"; + +const consumeCode = ( + originalImplementation: RecipeInterface, + 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.`, + ERROR_CODES.SIGNUP_FAILED_ERROR, + ); + } + + const originalResponse = await originalImplementation.consumeCode(input); + + if (originalResponse.status !== "OK") { + 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 emailDomain = + fastify.config.passwordless?.fallbackEmailDomain || + fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; + + const email = phoneNumber + ? `${phoneNumber}@${emailDomain}` + : originalResponse.user.email; + + if (!email || !phoneNumber) { + await deleteUser(originalResponse.user.id); + + throw new Error("Passwordless user missing phone number or email"); + } + + let user: null | undefined | User; + + if (originalResponse.createdNewUser) { + try { + user = await userService.create({ + email, + id: originalResponse.user.id, + phoneNumber, + }); + + if (!user) { + throw new Error("User not found"); + } + } 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 { + ...originalResponse, + user: { + ...originalResponse.user, + email, + phoneNumber, + }, + }; + }; +}; + +export default consumeCode; diff --git a/packages/passwordless/src/recipe/consumeCodePost.ts b/packages/passwordless/src/recipe/consumeCodePost.ts new file mode 100644 index 000000000..9a57220fc --- /dev/null +++ b/packages/passwordless/src/recipe/consumeCodePost.ts @@ -0,0 +1,115 @@ +import type { FastifyInstance } from "fastify"; +import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; + +import { ROLE_USER } from "@prefabs.tech/fastify-user"; +import Passwordless from "supertokens-node/recipe/passwordless"; + +import { TWILIO_VERIFY_PLACEHOLDER_CODE } from "../constants"; +import getTwilioClient from "../lib/getTwilioClient"; + +const enrichResult = ( + result: Awaited>>, + phoneNumber: string, + fallbackEmailDomain: string, +) => { + if (result.status !== "OK") { + return result; + } + + return { + ...result, + user: { + ...result.user, + email: result.user.email ?? `${phoneNumber}@${fallbackEmailDomain}`, + }, + }; +}; + +const consumeCodePOST = ( + originalImplementation: APIInterface, + fastify: FastifyInstance, +): APIInterface["consumeCodePOST"] => { + return async (input) => { + input.userContext.roles ||= [fastify.config.user.role || ROLE_USER]; + + if (originalImplementation.consumeCodePOST === undefined) { + throw new Error("Should never come here"); + } + + // Only handle user input code flows, not magic link flows + if (!("userInputCode" in input) || input.userInputCode === undefined) { + return originalImplementation.consumeCodePOST(input); + } + + const passwordless = fastify.config.passwordless; + + if (!passwordless) { + throw new Error("Passwordless recipe config is missing"); + } + + const isDevelopment = passwordless.enableDevMode === true; + + // Look up the device to retrieve the associated phone number + const deviceContext = await Passwordless.listCodesByPreAuthSessionId({ + preAuthSessionId: input.preAuthSessionId, + }); + + if (!deviceContext || !deviceContext.phoneNumber) { + return { status: "RESTART_FLOW_ERROR" }; + } + + const { phoneNumber } = deviceContext; + const bypassNumbers = passwordless.bypassSmsFor ?? []; + const fallbackEmailDomain = passwordless.fallbackEmailDomain ?? ""; + + // In dev mode or for bypassed numbers, skip Twilio Verify and let + // SuperTokens verify the code directly (uses devModeOtp) + if (isDevelopment || bypassNumbers.includes(phoneNumber)) { + return enrichResult( + await originalImplementation.consumeCodePOST(input), + phoneNumber, + fallbackEmailDomain, + ); + } + + let client, verifyServiceSid; + + try { + ({ client, verifyServiceSid } = getTwilioClient(passwordless.twilio)); + } catch (error) { + fastify.log.error(error); + + return { status: "RESTART_FLOW_ERROR" }; + } + + try { + const check = await client.verify.v2 + .services(verifyServiceSid) + .verificationChecks.create({ + code: input.userInputCode, + to: phoneNumber, + }); + + return check.status === "approved" + ? enrichResult( + await originalImplementation.consumeCodePOST({ + ...input, + userInputCode: TWILIO_VERIFY_PLACEHOLDER_CODE, + }), + phoneNumber, + fallbackEmailDomain, + ) + : { + failedCodeInputAttemptCount: 1, + maximumCodeInputAttempts: 5, + status: "INCORRECT_USER_INPUT_CODE_ERROR", + }; + } catch (error) { + fastify.log.error(error, "Twilio Verify verification check failed"); + + return { status: "RESTART_FLOW_ERROR" }; + } + }; +}; + +export default consumeCodePOST; diff --git a/packages/passwordless/src/recipe/initPasswordlessRecipe.ts b/packages/passwordless/src/recipe/initPasswordlessRecipe.ts new file mode 100644 index 000000000..e4f00a563 --- /dev/null +++ b/packages/passwordless/src/recipe/initPasswordlessRecipe.ts @@ -0,0 +1,17 @@ +import type { FastifyInstance } from "fastify"; + +import Passwordless from "supertokens-node/recipe/passwordless"; + +import getPasswordlessRecipeConfig from "./config"; + +const initPasswordlessRecipe = (fastify: FastifyInstance) => { + const recipe = fastify.config.passwordless?.recipe; + + if (typeof recipe === "function") { + return Passwordless.init(recipe(fastify)); + } + + return Passwordless.init(getPasswordlessRecipeConfig(fastify)); +}; + +export default initPasswordlessRecipe; diff --git a/packages/passwordless/src/types.ts b/packages/passwordless/src/types.ts new file mode 100644 index 000000000..cb91f646d --- /dev/null +++ b/packages/passwordless/src/types.ts @@ -0,0 +1,78 @@ +import type { FastifyInstance } from "fastify"; +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; +import type { + APIInterface, + TypeInput as PasswordlessRecipeConfig, + RecipeInterface, +} from "supertokens-node/recipe/passwordless/types"; + +type APIInterfaceWrapper = { + [key in keyof APIInterface]?: ( + originalImplementation: APIInterface, + fastify: FastifyInstance, + ) => APIInterface[key]; +}; + +interface PasswordlessConfig { + /** + * Phone numbers that skip Twilio entirely and are verified against + * `devModeOtp` instead. + */ + bypassSmsFor?: string[]; + /** + * @default "PHONE" + */ + contactMethod?: "EMAIL" | "EMAIL_OR_PHONE" | "PHONE"; + /** + * Required when `enableDevMode` is true. + */ + devModeOtp?: string; + /** + * @default true + */ + enabled?: boolean; + /** + * Skip Twilio and accept `devModeOtp` for every number. + * @default false + */ + enableDevMode?: boolean; + /** + * SuperTokens requires an email, so passwordless users get a synthetic + * `@` one. Defaults to the app name. + */ + fallbackEmailDomain?: string; + /** + * @default "USER_INPUT_CODE" + */ + flowType?: "USER_INPUT_CODE"; + override?: { + apis?: APIInterfaceWrapper; + functions?: RecipeInterfaceWrapper; + }; + /** + * Full escape hatch: replaces the generated recipe config entirely. + */ + recipe?: (fastify: FastifyInstance) => PasswordlessRecipeConfig; + twilio?: TwilioConfig; +} + +type RecipeInterfaceWrapper = { + [key in keyof RecipeInterface]?: ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, + ) => RecipeInterface[key]; +}; + +type TwilioConfig = Omit< + TwilioServiceConfig, + "from" | "messagingServiceSid" +> & { + verifyServiceSid: string; +}; + +export type { + APIInterfaceWrapper, + PasswordlessConfig, + RecipeInterfaceWrapper, + TwilioConfig, +}; diff --git a/packages/passwordless/tsconfig.json b/packages/passwordless/tsconfig.json new file mode 100644 index 000000000..1628077b9 --- /dev/null +++ b/packages/passwordless/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": ["src/**/__test__/**/*"], + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/passwordless/vite.config.ts b/packages/passwordless/vite.config.ts new file mode 100644 index 000000000..3f6ab0528 --- /dev/null +++ b/packages/passwordless/vite.config.ts @@ -0,0 +1,60 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, loadEnv } from "vite"; + +import { dependencies, peerDependencies } from "./package.json"; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => { + process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + + return { + build: { + lib: { + entry: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "src/index.ts", + ), + fileName: "prefabs-tech-fastify-passwordless", + formats: ["cjs", "es"], + name: "PrefabsTechFastifyPasswordless", + }, + rolldownOptions: { + external: [ + ...Object.keys(dependencies), + ...Object.keys(peerDependencies), + /supertokens-node+/, + ], + output: { + exports: "named", + globals: { + "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", + "@prefabs.tech/fastify-error-handler": + "PrefabsTechFastifyErrorHandler", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", + "@prefabs.tech/fastify-user": "PrefabsTechFastifyUser", + fastify: "Fastify", + "fastify-plugin": "FastifyPlugin", + slonik: "Slonik", + "supertokens-node": "SupertokensNode", + "supertokens-node/recipe/passwordless": "SupertokensPasswordless", + "supertokens-node/recipe/userroles": "SupertokensUserRoles", + twilio: "Twilio", + }, + }, + }, + target: "es2022", + }, + resolve: { + alias: { + "@/": new URL("src/", import.meta.url).pathname, + }, + }, + test: { + coverage: { + provider: "istanbul", + reporter: ["text", "json", "html"], + }, + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86bd4a4cc..c4367f5df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,6 +332,64 @@ importers: specifier: 3.2.7 version: 3.2.7(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.1) + packages/passwordless: + dependencies: + twilio: + specifier: 6.0.0 + version: 6.0.0 + devDependencies: + '@prefabs.tech/eslint-config': + specifier: 0.8.7 + version: 0.8.7(@typescript-eslint/parser@8.58.0(eslint@10.7.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.7.0(jiti@2.6.1))(prettier@3.9.5)(typescript@5.9.3) + '@prefabs.tech/fastify-config': + specifier: 0.94.1 + version: link:../config + '@prefabs.tech/fastify-error-handler': + specifier: 0.94.1 + version: link:../error-handler + '@prefabs.tech/fastify-slonik': + specifier: 0.94.1 + version: link:../slonik + '@prefabs.tech/fastify-user': + specifier: 0.94.1 + version: link:../user + '@prefabs.tech/tsconfig': + specifier: 0.8.7 + version: 0.8.7 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@vitest/coverage-istanbul': + specifier: 3.2.7 + version: 3.2.7(vitest@3.2.7(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.1)) + eslint: + specifier: 10.7.0 + version: 10.7.0(jiti@2.6.1) + fastify: + specifier: 5.10.0 + version: 5.10.0 + fastify-plugin: + specifier: 6.0.0 + version: 6.0.0 + prettier: + specifier: 3.9.5 + version: 3.9.5 + slonik: + specifier: 46.8.0 + version: 46.8.0(zod@3.25.76) + supertokens-node: + specifier: 14.1.4 + version: 14.1.4 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@24.13.3)(jiti@2.6.1)(yaml@2.8.1) + vitest: + specifier: 3.2.7 + version: 3.2.7(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.1) + packages/s3: dependencies: '@aws-sdk/client-s3': @@ -526,9 +584,6 @@ importers: humps: specifier: 2.0.1 version: 2.0.1 - twilio: - specifier: 6.0.0 - version: 6.0.0 validator: specifier: 13.15.35 version: 13.15.35 @@ -693,10 +748,6 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -868,18 +919,9 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -1418,14 +1460,6 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1542,10 +1576,6 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} - engines: {node: '>=8.0.0'} - '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1955,9 +1985,6 @@ packages: cpu: [arm64] os: [win32] - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -2407,9 +2434,6 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} - axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} @@ -2432,10 +2456,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.8.20: - resolution: {integrity: sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==} - hasBin: true - before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} @@ -2469,11 +2489,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.27.0: - resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.5: resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2531,9 +2546,6 @@ packages: camel-case@3.0.0: resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} - caniuse-lite@1.0.30001751: - resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} - caniuse-lite@1.0.30001803: resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} @@ -2898,9 +2910,6 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.240: - resolution: {integrity: sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==} - electron-to-chromium@1.5.389: resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} @@ -4059,10 +4068,6 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -4076,9 +4081,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -4086,9 +4088,6 @@ packages: resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} engines: {node: '>=14'} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -4336,10 +4335,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -4486,11 +4481,6 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4536,9 +4526,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.26: - resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} - node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -4833,10 +4820,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -4867,10 +4850,6 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -4974,10 +4953,6 @@ packages: resolution: {integrity: sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==} engines: {node: '>= 16'} - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -5230,10 +5205,6 @@ packages: engines: {node: '>=20'} hasBin: true - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -5246,10 +5217,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - side-channel@1.1.1: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} @@ -5486,10 +5453,6 @@ packages: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -5625,12 +5588,6 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.1.4: - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -6146,12 +6103,6 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6162,7 +6113,7 @@ snapshots: '@babel/core@7.28.5': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@babel/generator': 7.28.5 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) @@ -6192,7 +6143,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.5 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.27.0 + browserslist: 4.28.5 lru-cache: 5.1.1 semver: 6.3.1 @@ -6233,13 +6184,13 @@ snapshots: '@babel/template@7.27.2': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 '@babel/traverse@7.28.5': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.5 @@ -6386,27 +6337,11 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.8.1': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -6985,12 +6920,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.15 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7028,9 +6957,9 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 optional: true '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -7125,10 +7054,7 @@ snapshots: '@one-ini/wasm@0.1.1': {} - '@opentelemetry/api@1.9.0': {} - - '@opentelemetry/api@1.9.1': - optional: true + '@opentelemetry/api@1.9.1': {} '@oxc-project/types@0.139.0': {} @@ -7354,7 +7280,7 @@ snapshots: dependencies: '@slack/types': 2.20.0 '@types/node': 24.13.3 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: - debug @@ -7460,11 +7386,6 @@ snapshots: '@turbo/windows-arm64@2.10.6': optional: true - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -7640,7 +7561,7 @@ snapshots: debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -7947,15 +7868,7 @@ snapshots: axe-core@4.11.1: {} - axios@1.12.2(debug@4.4.3): - dependencies: - follow-redirects: 1.15.11(debug@4.4.3) - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axios@1.13.5: + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) form-data: 4.0.5 @@ -7973,8 +7886,6 @@ snapshots: baseline-browser-mapping@2.10.42: {} - baseline-browser-mapping@2.8.20: {} - before-after-hook@3.0.2: {} bignumber.js@9.3.1: {} @@ -8004,14 +7915,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.27.0: - dependencies: - baseline-browser-mapping: 2.8.20 - caniuse-lite: 1.0.30001751 - electron-to-chromium: 1.5.240 - node-releases: 2.0.26 - update-browserslist-db: 1.1.4(browserslist@4.27.0) - browserslist@4.28.5: dependencies: baseline-browser-mapping: 2.10.42 @@ -8077,8 +7980,6 @@ snapshots: no-case: 2.3.2 upper-case: 1.1.3 - caniuse-lite@1.0.30001751: {} - caniuse-lite@1.0.30001803: {} chai@5.3.3: @@ -8443,8 +8344,6 @@ snapshots: dependencies: jake: 10.9.4 - electron-to-chromium@1.5.240: {} - electron-to-chromium@1.5.389: {} emoji-regex@10.6.0: {} @@ -8633,7 +8532,7 @@ snapshots: get-tsconfig: 4.13.1 is-bun-module: 2.0.0 stable-hash-x: 0.2.0 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 unrs-resolver: 1.11.1 optionalDependencies: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@10.7.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.7.0(jiti@2.6.1)) @@ -9093,10 +8992,6 @@ snapshots: dependencies: walk-up-path: 3.0.1 - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -9343,7 +9238,7 @@ snapshots: glob@13.0.0: dependencies: - minimatch: 10.1.1 + minimatch: 10.2.5 minipass: 7.1.2 path-scurry: 2.0.0 @@ -9653,7 +9548,7 @@ snapshots: dependencies: es-errors: 1.3.0 hasown: 2.0.3 - side-channel: 1.1.0 + side-channel: 1.1.1 ipaddr.js@2.4.0: {} @@ -9928,19 +9823,6 @@ snapshots: jsonify@0.0.1: {} - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.3 - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -9971,12 +9853,6 @@ snapshots: transitivePeerDependencies: - encoding - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -9993,11 +9869,6 @@ snapshots: transitivePeerDependencies: - supports-color - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - jws@4.0.1: dependencies: jwa: 2.0.1 @@ -10214,10 +10085,6 @@ snapshots: mime@3.0.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -10554,8 +10421,6 @@ snapshots: mute-stream@2.0.0: {} - nanoid@3.3.11: {} - nanoid@3.3.16: {} napi-postinstall@0.3.4: {} @@ -10589,8 +10454,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.26: {} - node-releases@2.0.51: {} nodemailer-html-to-text@3.2.0: @@ -10861,8 +10724,6 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.3: {} - picomatch@4.0.5: {} pino-abstract-transport@3.0.0: @@ -10900,12 +10761,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postgres-array@2.0.0: {} postgres-array@3.0.4: {} @@ -10987,10 +10842,6 @@ snapshots: qlobber@8.0.1: {} - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -11301,11 +11152,6 @@ snapshots: - conventional-commits-filter - debug - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -11326,14 +11172,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - side-channel@1.1.1: dependencies: es-errors: 1.3.0 @@ -11363,7 +11201,7 @@ snapshots: slonik@46.8.0(zod@3.25.76): dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@slonik/driver': 46.8.0(zod@3.25.76) '@slonik/errors': 46.8.0(zod@3.25.76) '@slonik/pg-driver': 46.8.0(zod@3.25.76) @@ -11604,11 +11442,6 @@ snapshots: tinyexec@1.1.2: {} - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -11656,11 +11489,11 @@ snapshots: twilio@4.23.0(debug@4.4.3): dependencies: - axios: 1.12.2(debug@4.4.3) + axios: 1.13.5(debug@4.4.3) dayjs: 1.11.18 https-proxy-agent: 5.0.1 - jsonwebtoken: 9.0.2 - qs: 6.14.0 + jsonwebtoken: 9.0.3 + qs: 6.15.3 scmp: 2.1.0 url-parse: 1.5.10 xmlbuilder: 13.0.2 @@ -11670,7 +11503,7 @@ snapshots: twilio@6.0.0: dependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) dayjs: 1.11.18 https-proxy-agent: 5.0.1 jsonwebtoken: 9.0.3 @@ -11786,12 +11619,6 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.1.4(browserslist@4.27.0): - dependencies: - browserslist: 4.27.0 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.5): dependencies: browserslist: 4.28.5 @@ -11854,11 +11681,11 @@ snapshots: vite@6.4.3(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.1): dependencies: esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.19 rollup: 4.52.5 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 fsevents: 2.3.3 @@ -11894,11 +11721,11 @@ snapshots: expect-type: 1.2.2 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.5 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 6.4.3(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.1) From 79852175097d7556d45974514057f71eb737a087 Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 28 Jul 2026 13:21:08 +0545 Subject: [PATCH 26/37] feat(user): add phone_number column to users table --- packages/user/src/migrations/queries.ts | 12 ++++++++++++ packages/user/src/migrations/runMigrations.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/packages/user/src/migrations/queries.ts b/packages/user/src/migrations/queries.ts index 04064b019..01aedd258 100644 --- a/packages/user/src/migrations/queries.ts +++ b/packages/user/src/migrations/queries.ts @@ -14,6 +14,17 @@ import { TABLE_USERS, } from "../constants"; +const addPhoneNumberInUsersTableQuery = ( + config: ApiConfig, +): QuerySqlToken => { + const users = config.user.tables?.users?.name || TABLE_USERS; + + return sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 ); + `; +}; + const addProfileInUsersTableQuery = ( config: ApiConfig, ): QuerySqlToken => { @@ -159,6 +170,7 @@ const createProfileFieldsTablesQueries = ( }; export { + addPhoneNumberInUsersTableQuery, addProfileInUsersTableQuery, createInvitationsTableQuery, createProfileFieldsTablesQueries, diff --git a/packages/user/src/migrations/runMigrations.ts b/packages/user/src/migrations/runMigrations.ts index 97e28040f..a66b9c414 100644 --- a/packages/user/src/migrations/runMigrations.ts +++ b/packages/user/src/migrations/runMigrations.ts @@ -2,6 +2,7 @@ import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; import { + addPhoneNumberInUsersTableQuery, addProfileInUsersTableQuery, createInvitationsTableQuery, createProfileFieldsTablesQueries, @@ -12,6 +13,9 @@ const runMigrations = async (config: ApiConfig, database: Database) => { await database.connect(async (connection) => { await connection.transaction(async (transactionConnection) => { await transactionConnection.query(createUsersTableQuery(config)); + await transactionConnection.query( + addPhoneNumberInUsersTableQuery(config), + ); await transactionConnection.query(createInvitationsTableQuery(config)); if (config.user.features?.profileFields?.enabled) { From 1392b1589fdf202919fce012ae6955afb94922bc Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 28 Jul 2026 13:22:13 +0545 Subject: [PATCH 27/37] feat(user): add SuperTokens recipe registry and user phoneNumber field --- packages/user/src/index.ts | 5 + .../__test__/filterUserUpdateInput.spec.ts | 18 +++ .../src/model/users/filterUserUpdateInput.ts | 1 + .../user/src/model/users/graphql/schema.ts | 1 + packages/user/src/model/users/schema.ts | 1 + .../__test__/recipeRegistry.spec.ts | 103 ++++++++++++++++++ packages/user/src/supertokens/init.ts | 2 + .../user/src/supertokens/recipeRegistry.ts | 26 +++++ .../user/src/supertokens/recipes/index.ts | 11 +- 9 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 packages/user/src/supertokens/__test__/recipeRegistry.spec.ts create mode 100644 packages/user/src/supertokens/recipeRegistry.ts diff --git a/packages/user/src/index.ts b/packages/user/src/index.ts index 86454c421..27037b896 100644 --- a/packages/user/src/index.ts +++ b/packages/user/src/index.ts @@ -1,3 +1,4 @@ +import type { SupertokensRecipeFactory } from "./supertokens/types"; import type { User, UserConfig } from "./types"; import hasPermission from "./middlewares/hasPermission"; @@ -5,6 +6,8 @@ import hasPermission from "./middlewares/hasPermission"; declare module "fastify" { interface FastifyInstance { hasPermission: typeof hasPermission; + supertokensInitialized?: boolean; + supertokensRecipes?: SupertokensRecipeFactory[]; } interface FastifyRequest { @@ -58,6 +61,8 @@ export { export { default as UserSqlFactory } from "./model/users/sqlFactory"; export { default } from "./plugin"; export { errorHandler as supertokensErrorHandler } from "./supertokens/errorHandler"; +export { default as addSupertokensRecipe } from "./supertokens/recipeRegistry"; +export type { SupertokensRecipeFactory } from "./supertokens/types"; export { default as areRolesExist } from "./supertokens/utils/areRolesExist"; export { default as createUserContext } from "./supertokens/utils/createUserContext"; export { default as isRoleExists } from "./supertokens/utils/isRoleExists"; diff --git a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts index 08d050ab8..d62ecc3d3 100644 --- a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts +++ b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts @@ -32,6 +32,24 @@ describe("filterUserUpdateInput", () => { expect(updateInput).not.toHaveProperty("id"); }); + it("removes camelCase 'phoneNumber'", () => { + const updateInput = { phoneNumber: "+15550001111" } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("phoneNumber"); + }); + + it("removes snake_case 'phone_number' (camelized to phoneNumber)", () => { + const updateInput = { + phone_number: "+15550001111", + } as unknown as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("phone_number"); + }); + it("removes 'roles'", () => { const updateInput = { roles: ["ADMIN"] } as UserUpdateInput; diff --git a/packages/user/src/model/users/filterUserUpdateInput.ts b/packages/user/src/model/users/filterUserUpdateInput.ts index 44861ca3b..baf1c2dc9 100644 --- a/packages/user/src/model/users/filterUserUpdateInput.ts +++ b/packages/user/src/model/users/filterUserUpdateInput.ts @@ -8,6 +8,7 @@ const ignoredUpdateKeys = new Set([ "enable", "id", "lastLoginAt", + "phoneNumber", "roles", "signedUpAt", ]) as Set; diff --git a/packages/user/src/model/users/graphql/schema.ts b/packages/user/src/model/users/graphql/schema.ts index a225cb20f..998261d19 100644 --- a/packages/user/src/model/users/graphql/schema.ts +++ b/packages/user/src/model/users/graphql/schema.ts @@ -9,6 +9,7 @@ const user = gql` disabled: Boolean! email: String! lastLoginAt: Float! + phoneNumber: String photoId: Int photo: Photo roles: [String] diff --git a/packages/user/src/model/users/schema.ts b/packages/user/src/model/users/schema.ts index 630204424..f639a7e96 100644 --- a/packages/user/src/model/users/schema.ts +++ b/packages/user/src/model/users/schema.ts @@ -6,6 +6,7 @@ export const userSchema = { email: { format: "email", type: "string" }, id: { type: "string" }, lastLoginAt: { type: "number" }, + phoneNumber: { nullable: true, type: "string" }, photoId: { nullable: true, type: "number" }, roles: { items: { type: "string" }, type: "array" }, signedUpAt: { type: "number" }, diff --git a/packages/user/src/supertokens/__test__/recipeRegistry.spec.ts b/packages/user/src/supertokens/__test__/recipeRegistry.spec.ts new file mode 100644 index 000000000..522562eb7 --- /dev/null +++ b/packages/user/src/supertokens/__test__/recipeRegistry.spec.ts @@ -0,0 +1,103 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import addSupertokensRecipe from "../recipeRegistry"; + +// The individual recipe inits reach SuperTokens' global singleton; the registry +// itself is what is under test, so they are stubbed out. +vi.mock("../recipes/initSessionRecipe", () => ({ default: () => "session" })); +vi.mock("../recipes/initThirdPartyEmailPasswordRecipe", () => ({ + default: () => "thirdPartyEmailPassword", +})); +vi.mock("../recipes/initUserRolesRecipe", () => ({ + default: () => "userRoles", +})); +vi.mock("../recipes/initEmailVerificationRecipe", () => ({ + default: () => "emailVerification", +})); + +const { default: getRecipeList } = await import("../recipes"); + +const buildFastify = (): FastifyInstance => { + const fastify = Fastify({ logger: false }); + + fastify.decorate("config", { user: { supertokens: {} } }); + + return fastify; +}; + +describe("addSupertokensRecipe", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("creates the registry on first use", () => { + fastify = buildFastify(); + + addSupertokensRecipe(fastify, () => "passwordless"); + + expect(fastify.supertokensRecipes).toHaveLength(1); + }); + + it("appends to an existing registry", () => { + fastify = buildFastify(); + + addSupertokensRecipe(fastify, () => "one"); + addSupertokensRecipe(fastify, () => "two"); + + expect(fastify.supertokensRecipes).toHaveLength(2); + }); + + it("throws when SuperTokens has already been initialised", () => { + fastify = buildFastify(); + fastify.decorate("supertokensInitialized", true); + + expect(() => addSupertokensRecipe(fastify, () => "late")).toThrow( + /Register SuperTokens recipe plugins before @prefabs.tech\/fastify-user/, + ); + }); +}); + +describe("getRecipeList", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("returns the always-on recipes when the registry is empty", () => { + fastify = buildFastify(); + + expect(getRecipeList(fastify)).toStrictEqual([ + "session", + "thirdPartyEmailPassword", + "userRoles", + ]); + }); + + it("drains registered recipe factories", () => { + fastify = buildFastify(); + + addSupertokensRecipe(fastify, () => "passwordless"); + + expect(getRecipeList(fastify)).toContain("passwordless"); + }); + + it("passes the fastify instance to each registered factory", () => { + fastify = buildFastify(); + + const factory = vi.fn(() => "passwordless"); + + addSupertokensRecipe(fastify, factory); + getRecipeList(fastify); + + // Identity check rather than toHaveBeenCalledWith: deep-equalling a Fastify + // instance touches getters that throw before the server is listening. + expect(factory.mock.calls[0][0]).toBe(fastify); + }); +}); diff --git a/packages/user/src/supertokens/init.ts b/packages/user/src/supertokens/init.ts index 05fb5b751..b39c33c61 100644 --- a/packages/user/src/supertokens/init.ts +++ b/packages/user/src/supertokens/init.ts @@ -20,6 +20,8 @@ const init = (fastify: FastifyInstance) => { connectionURI: config.user.supertokens.connectionUri as string, }, }); + + fastify.decorate("supertokensInitialized", true); }; export default init; diff --git a/packages/user/src/supertokens/recipeRegistry.ts b/packages/user/src/supertokens/recipeRegistry.ts new file mode 100644 index 000000000..a4ffb58b2 --- /dev/null +++ b/packages/user/src/supertokens/recipeRegistry.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from "fastify"; + +import type { SupertokensRecipeFactory } from "./types"; + +// SuperTokens allows exactly one global init(), which happens while +// @prefabs.tech/fastify-user is being registered. Plugins that contribute a +// recipe therefore have to be registered BEFORE it, so the factory is in the +// registry by the time getRecipeList() drains it. +const addSupertokensRecipe = ( + fastify: FastifyInstance, + factory: SupertokensRecipeFactory, +): void => { + if (fastify.hasDecorator("supertokensInitialized")) { + throw new Error( + "SuperTokens is already initialised. Register SuperTokens recipe plugins before @prefabs.tech/fastify-user.", + ); + } + + if (!fastify.hasDecorator("supertokensRecipes")) { + fastify.decorate("supertokensRecipes", []); + } + + fastify.supertokensRecipes?.push(factory); +}; + +export default addSupertokensRecipe; diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 93b3d0b12..83c8f448e 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -2,7 +2,6 @@ import type { FastifyInstance } from "fastify"; import type { RecipeListFunction } from "supertokens-node/types"; import initEmailVerificationRecipe from "./initEmailVerificationRecipe"; -import initPasswordlessRecipe from "./initPasswordlessRecipe"; import initSessionRecipe from "./initSessionRecipe"; import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe"; import initUserRolesRecipe from "./initUserRolesRecipe"; @@ -14,14 +13,16 @@ const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { initUserRolesRecipe(fastify), ]; - if (fastify.config.user.features?.passwordlessLogin?.enabled) { - recipeList.push(initPasswordlessRecipe(fastify)); - } - if (fastify.config.user.features?.signUp?.emailVerification) { recipeList.push(initEmailVerificationRecipe(fastify)); } + const registeredRecipes = fastify.supertokensRecipes ?? []; + + for (const factory of registeredRecipes) { + recipeList.push(factory(fastify)); + } + return recipeList; }; From db235b52a720a25b70d91ca2080a97ad2efb339b Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 28 Jul 2026 13:22:58 +0545 Subject: [PATCH 28/37] docs: update documentation and remove unsued dependency --- packages/user/FEATURES.md | 152 +++++++++++++++++++------------------ packages/user/GUIDE.md | 29 ++++++- packages/user/package.json | 1 - 3 files changed, 105 insertions(+), 77 deletions(-) diff --git a/packages/user/FEATURES.md b/packages/user/FEATURES.md index 2d078e63a..2bd2d75f2 100644 --- a/packages/user/FEATURES.md +++ b/packages/user/FEATURES.md @@ -8,84 +8,86 @@ 2. **Selective route module disabling** — each of the four route groups (`users`, `invitations`, `roles`, `permissions`) can be disabled independently via `routes..disabled = true`. The service layer is unaffected. -3. **Automatic database migrations** — on registration, runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables before the server is ready. +3. **Automatic database migrations** — on registration, runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables before the server is ready, plus an idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number` on the users table. The matching `phoneNumber?: string` field is present on `User` and `UserCreateInput`, exposed on the REST and GraphQL `User` types, and omitted from `UserUpdateInput` — it is written by the sign-up flow (see `@prefabs.tech/fastify-passwordless`), not by profile edits. 4. **Default role seeding** — on `onReady`, seeds `ADMIN`, `SUPERADMIN`, and `USER` into SuperTokens, plus any extra roles listed in `config.user.roles`. +5. **SuperTokens recipe registry** — `addSupertokensRecipe(fastify, factory)` lets another plugin contribute a SuperTokens recipe. Factories are collected on the `fastify.supertokensRecipes` decorator and drained by `getRecipeList` during `supertokens.init()`. Since SuperTokens allows exactly one global `init()` and this package performs it during its own registration, contributing plugins must be registered **before** it; `addSupertokensRecipe` throws once `fastify.supertokensInitialized` is set. Used by `@prefabs.tech/fastify-passwordless`. + ## Authentication -5. **`fastify.verifySession()` decorator** — added to the Fastify instance; use it as a `preHandler` to require a valid SuperTokens session on any route. +6. **`fastify.verifySession()` decorator** — added to the Fastify instance; use it as a `preHandler` to require a valid SuperTokens session on any route. -6. **`req.session` request property** — `FastifyRequest` is augmented with an optional `session` property (populated by SuperTokens after `verifySession` runs). +7. **`req.session` request property** — `FastifyRequest` is augmented with an optional `session` property (populated by SuperTokens after `verifySession` runs). -7. **`req.user` request property** — `FastifyRequest` is augmented with an optional `user: User` property, populated from the database on every verified session. +8. **`req.user` request property** — `FastifyRequest` is augmented with an optional `user: User` property, populated from the database on every verified session. -8. **Configurable refresh-token cookie path** — an `onSend` hook rewrites the `Path` attribute of the `sRefreshToken` cookie to the value of `config.user.supertokens.refreshTokenCookiePath`, so the refresh token is scoped to the refresh endpoint. +9. **Configurable refresh-token cookie path** — an `onSend` hook rewrites the `Path` attribute of the `sRefreshToken` cookie to the value of `config.user.supertokens.refreshTokenCookiePath`, so the refresh token is scoped to the refresh endpoint. -9. **`SUPERTOKENS_CORS_HEADERS` constant** — exports the eight SuperTokens-specific request headers that must be included in `allowedHeaders` when registering `@fastify/cors`: +10. **`SUPERTOKENS_CORS_HEADERS` constant** — exports the eight SuperTokens-specific request headers that must be included in `allowedHeaders` when registering `@fastify/cors`: ``` anti-csrf, authorization, fdi-version, front-token, rid, st-access-token, st-auth-mode, st-refresh-token ``` -10. **SuperTokens error handler auto-registration** — automatically calls `fastify.setErrorHandler(supertokensErrorHandler)` unless `config.user.supertokens.setErrorHandler === false`. +11. **SuperTokens error handler auto-registration** — automatically calls `fastify.setErrorHandler(supertokensErrorHandler)` unless `config.user.supertokens.setErrorHandler === false`. -11. **`supertokensErrorHandler` export** — exported for manual wiring when auto-registration is disabled. +12. **`supertokensErrorHandler` export** — exported for manual wiring when auto-registration is disabled. -12. **Session recipe override via function factory** — each SuperTokens recipe (`session`, `thirdPartyEmailPassword`, `userRoles`, `emailVerification`) can be overridden by supplying a function `(fastify) => RecipeConfig` under `config.user.supertokens.recipes`. The function receives the Fastify instance, enabling access to config and decorators. Providing an object instead of a function merges the object into the default config. +13. **Session recipe override via function factory** — each SuperTokens recipe (`session`, `thirdPartyEmailPassword`, `userRoles`, `emailVerification`) can be overridden by supplying a function `(fastify) => RecipeConfig` under `config.user.supertokens.recipes`. The function receives the Fastify instance, enabling access to config and decorators. Providing an object instead of a function merges the object into the default config. -13. **Override merging for `apis` and `functions`** — when a recipe override includes `override.apis` or `override.functions`, each key is called as `fn(originalImplementation, fastify)` and merged on top of the default implementation, so only the keys you provide are replaced. +14. **Override merging for `apis` and `functions`** — when a recipe override includes `override.apis` or `override.functions`, each key is called as `fn(originalImplementation, fastify)` and merged on top of the default implementation, so only the keys you provide are replaced. -14. **Email verification (opt-in)** — setting `config.user.features.signUp.emailVerification = true` adds the `EmailVerification` recipe and enforces the email-verified claim on protected routes. Default: `false`. +15. **Email verification (opt-in)** — setting `config.user.features.signUp.emailVerification = true` adds the `EmailVerification` recipe and enforces the email-verified claim on protected routes. Default: `false`. -15. **Third-party OAuth providers** — Apple, Facebook, GitHub, and Google providers are configurable via `config.user.supertokens.providers`; custom providers are supported via `providers.custom`. +16. **Third-party OAuth providers** — Apple, Facebook, GitHub, and Google providers are configurable via `config.user.supertokens.providers`; custom providers are supported via `providers.custom`. ## User Management -16. **`GET /me`** — returns the authenticated user's profile. If a photo exists, the `photo.url` field is a pre-signed S3 URL. Session claims (email verification, profile validation) are bypassed so users can always read their own data. +17. **`GET /me`** — returns the authenticated user's profile. If a photo exists, the `photo.url` field is a pre-signed S3 URL. Session claims (email verification, profile validation) are bypassed so users can always read their own data. -17. **`PUT /me`** — updates mutable fields on the current user's profile. Session claims are bypassed. +18. **`PUT /me`** — updates mutable fields on the current user's profile. Session claims are bypassed. -18. **`POST /change-email`** — updates the authenticated user's email address. Gated by `config.user.features.updateEmail.enabled`. Session email-verification claims are bypassed on this route. +19. **`POST /change-email`** — updates the authenticated user's email address. Gated by `config.user.features.updateEmail.enabled`. Session email-verification claims are bypassed on this route. -19. **`POST /change_password`** — validates the current password before updating. Requires a valid session. +20. **`POST /change_password`** — validates the current password before updating. Requires a valid session. -20. **`DELETE /me` with atomic session revocation** — soft-deletes the user record (`deleted_at`) and immediately revokes all active SuperTokens sessions in the same operation. Requires password confirmation. +21. **`DELETE /me` with atomic session revocation** — soft-deletes the user record (`deleted_at`) and immediately revokes all active SuperTokens sessions in the same operation. Requires password confirmation. -21. **`PUT /me/photo`** — accepts `multipart/form-data`, validates MIME type (`image/jpeg`, `image/png`, `image/webp`) and file size, uploads to `{userId}/photo` in the configured S3 bucket, and links the file record to the user. Session claims bypassed. +22. **`PUT /me/photo`** — accepts `multipart/form-data`, validates MIME type (`image/jpeg`, `image/png`, `image/webp`) and file size, uploads to `{userId}/photo` in the configured S3 bucket, and links the file record to the user. Session claims bypassed. -22. **`DELETE /me/photo`** — deletes the photo from S3 and unlinks it from the user record. Session claims bypassed. +23. **`DELETE /me/photo`** — deletes the photo from S3 and unlinks it from the user record. Session claims bypassed. -23. **Configurable photo size limit** — `config.user.photoMaxSizeInMB` (default: `5`). +24. **Configurable photo size limit** — `config.user.photoMaxSizeInMB` (default: `5`). -24. **`POST /signup/admin`** — public endpoint to create the first administrator account without an invitation. +25. **`POST /signup/admin`** — public endpoint to create the first administrator account without an invitation. -25. **`GET /signup/admin`** — public endpoint returning `{ signUp: boolean }` indicating whether admin sign-up is currently available. +26. **`GET /signup/admin`** — public endpoint returning `{ signUp: boolean }` indicating whether admin sign-up is currently available. -26. **`GET /users`** — paginatable list of all users. Requires `users:list` permission. +27. **`GET /users`** — paginatable list of all users. Requires `users:list` permission. -27. **`GET /users/:id`** — fetches a single user by ID. Requires `users:read` permission. +28. **`GET /users/:id`** — fetches a single user by ID. Requires `users:read` permission. -28. **`PUT /users/:id/disable`** — sets the user's `disabled` flag to `true`. Requires `users:disable` permission. +29. **`PUT /users/:id/disable`** — sets the user's `disabled` flag to `true`. Requires `users:disable` permission. -29. **`PUT /users/:id/enable`** — clears the user's `disabled` flag. Requires `users:enable` permission. +30. **`PUT /users/:id/enable`** — clears the user's `disabled` flag. Requires `users:enable` permission. -30. **Immutable field guard (`filterUserUpdateInput`)** — applied automatically before every profile update; silently drops any attempt to set `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disable`, or `enable`. Handles both camelCase and snake_case variants (e.g. `last_login_at` is also stripped). +31. **Immutable field guard (`filterUserUpdateInput`)** — applied automatically before every profile update; silently drops any attempt to set `id`, `email`, `roles`, `lastLoginAt`, `phoneNumber`, `signedUpAt`, `disable`, or `enable`. Handles both camelCase and snake_case variants (e.g. `last_login_at` is also stripped). -31. **Configurable table names** — `config.user.tables.users.name` and `config.user.tables.invitations.name` override the default table names. +32. **Configurable table names** — `config.user.tables.users.name` and `config.user.tables.invitations.name` override the default table names. -32. **Custom request handlers** — every route handler can be replaced via `config.user.handlers.user.` or `config.user.handlers.invitation.`. +33. **Custom request handlers** — every route handler can be replaced via `config.user.handlers.user.` or `config.user.handlers.invitation.`. ## Authorization -33. **`fastify.hasPermission(permission)` decorator** — added to the Fastify instance; returns a `preHandler` that checks the authenticated user holds the given permission. Returns 401 without a session, 403 without the permission. +34. **`fastify.hasPermission(permission)` decorator** — added to the Fastify instance; returns a `preHandler` that checks the authenticated user holds the given permission. Returns 401 without a session, 403 without the permission. -34. **`hasUserPermission(fastify, userId, permission)` utility** — programmatic permission check; returns a boolean. +35. **`hasUserPermission(fastify, userId, permission)` utility** — programmatic permission check; returns a boolean. -35. **SUPERADMIN bypass** — users with the `SUPERADMIN` role pass all `hasPermission` and `hasUserPermission` checks automatically, without being explicitly granted every permission. +36. **SUPERADMIN bypass** — users with the `SUPERADMIN` role pass all `hasPermission` and `hasUserPermission` checks automatically, without being explicitly granted every permission. -36. **Built-in permission constants** — pre-defined strings to avoid typos: +37. **Built-in permission constants** — pre-defined strings to avoid typos: ``` PERMISSIONS_INVITATIONS_CREATE → "invitations:create" @@ -99,100 +101,100 @@ PERMISSIONS_USERS_READ → "users:read" ``` -37. **Application-defined custom permissions** — `config.user.permissions` registers additional permission strings returned by `GET /permissions`, making them discoverable by role-management UIs. +38. **Application-defined custom permissions** — `config.user.permissions` registers additional permission strings returned by `GET /permissions`, making them discoverable by role-management UIs. ## Roles -38. **Built-in role constants** — `ROLE_ADMIN`, `ROLE_SUPERADMIN`, `ROLE_USER` are exported. +39. **Built-in role constants** — `ROLE_ADMIN`, `ROLE_SUPERADMIN`, `ROLE_USER` are exported. -39. **`POST /roles`** — creates a new role with optional initial permissions. Requires a valid session. +40. **`POST /roles`** — creates a new role with optional initial permissions. Requires a valid session. -40. **`DELETE /roles`** — deletes a role; returns `ROLE_IN_USE` error if any user holds it. Requires a valid session. +41. **`DELETE /roles`** — deletes a role; returns `ROLE_IN_USE` error if any user holds it. Requires a valid session. -41. **`GET /roles`** — returns all roles with their permissions. Requires a valid session. +42. **`GET /roles`** — returns all roles with their permissions. Requires a valid session. -42. **`GET /roles/permissions`** — returns the permissions for a named role. Requires a valid session. +43. **`GET /roles/permissions`** — returns the permissions for a named role. Requires a valid session. -43. **`PUT /roles/permissions`** — replaces the permission set of a named role. Requires a valid session. +44. **`PUT /roles/permissions`** — replaces the permission set of a named role. Requires a valid session. -44. **`isRoleExists(name)` / `areRolesExist(names)` utilities** — programmatic existence checks against SuperTokens. +45. **`isRoleExists(name)` / `areRolesExist(names)` utilities** — programmatic existence checks against SuperTokens. ## Invitations -45. **`POST /invitations`** — creates an invitation record, validates the target email and role, checks for a duplicate pending invitation, and sends the invitation email. Requires `invitations:create` permission. +46. **`POST /invitations`** — creates an invitation record, validates the target email and role, checks for a duplicate pending invitation, and sends the invitation email. Requires `invitations:create` permission. -46. **Configurable invitation expiry** — `config.user.invitation.expireAfterInDays` sets how long an invitation is valid (default: `30`). +47. **Configurable invitation expiry** — `config.user.invitation.expireAfterInDays` sets how long an invitation is valid (default: `30`). -47. **Configurable accept link path** — `config.user.invitation.acceptLinkPath` sets the front-end path embedded in the invitation email (default: `"/signup/token/:token"`). The `:token` placeholder is replaced with the actual token. +48. **Configurable accept link path** — `config.user.invitation.acceptLinkPath` sets the front-end path embedded in the invitation email (default: `"/signup/token/:token"`). The `:token` placeholder is replaced with the actual token. -48. **`GET /invitations/token/:token`** — public endpoint returning the invitation record for UI display before acceptance. +49. **`GET /invitations/token/:token`** — public endpoint returning the invitation record for UI display before acceptance. -49. **`POST /invitations/token/:token`** — public endpoint that validates the invitation, creates a SuperTokens account, opens a session, and optionally calls `config.user.invitation.postAccept(request, invitation, user)`. +50. **`POST /invitations/token/:token`** — public endpoint that validates the invitation, creates a SuperTokens account, opens a session, and optionally calls `config.user.invitation.postAccept(request, invitation, user)`. -50. **`GET /invitations`** — paginatable list of all invitations. Requires `invitations:list` permission. +51. **`GET /invitations`** — paginatable list of all invitations. Requires `invitations:list` permission. -51. **`PUT /invitations/revoke/:id`** — marks an invitation as revoked. Requires `invitations:revoke` permission. +52. **`PUT /invitations/revoke/:id`** — marks an invitation as revoked. Requires `invitations:revoke` permission. -52. **`POST /invitations/resend/:id`** — re-sends the invitation email. Requires `invitations:resend` permission. +53. **`POST /invitations/resend/:id`** — re-sends the invitation email. Requires `invitations:resend` permission. -53. **`DELETE /invitations/:id`** — permanently removes an invitation record. Requires `invitations:delete` permission. +54. **`DELETE /invitations/:id`** — permanently removes an invitation record. Requires `invitations:delete` permission. -54. **`isInvitationValid(invitation)` utility** — returns `true` only when the invitation is pending, non-expired, non-revoked, and non-accepted. +55. **`isInvitationValid(invitation)` utility** — returns `true` only when the invitation is pending, non-expired, non-revoked, and non-accepted. -55. **`computeInvitationExpiresAt(config, explicitDate?)` utility** — computes the expiry timestamp using the configured `expireAfterInDays`, or returns `explicitDate` when provided. +56. **`computeInvitationExpiresAt(config, explicitDate?)` utility** — computes the expiry timestamp using the configured `expireAfterInDays`, or returns `explicitDate` when provided. -56. **`getOrigin(url)` utility** — extracts `scheme://host[:non-default-port]` from a URL string. Returns an empty string for bare hostnames, IP addresses without a scheme, relative paths, or any input that is not a full URL. Default ports (`80` / `443`) are stripped. +57. **`getOrigin(url)` utility** — extracts `scheme://host[:non-default-port]` from a URL string. Returns an empty string for bare hostnames, IP addresses without a scheme, relative paths, or any input that is not a full URL. Default ports (`80` / `443`) are stripped. -57. **`sendInvitation(fastify, invitation, origin)` utility** — sends the invitation email; usable from custom code that bypasses the REST route. +58. **`sendInvitation(fastify, invitation, origin)` utility** — sends the invitation email; usable from custom code that bypasses the REST route. ## Email -58. **`validateEmail(email, config)` utility** — validates an email string against `config.user.email` options using `validator.js`. Returns `{ success: true }` or `{ success: false, message }`. Gracefully falls back to permissive defaults when no email config is provided. +59. **`validateEmail(email, config)` utility** — validates an email string against `config.user.email` options using `validator.js`. Returns `{ success: true }` or `{ success: false, message }`. Gracefully falls back to permissive defaults when no email config is provided. -59. **Email domain whitelist / blacklist** — `config.user.email.host_whitelist` and `config.user.email.host_blacklist` restrict which domains are accepted during sign-up and invitation. +60. **Email domain whitelist / blacklist** — `config.user.email.host_whitelist` and `config.user.email.host_blacklist` restrict which domains are accepted during sign-up and invitation. -60. **Custom email subjects and templates** — `config.user.emailOverrides` overrides the subject and `templateName` for any of the five system emails: `invitation`, `resetPassword`, `resetPasswordNotification`, `emailVerification`, `duplicateEmail`. +61. **Custom email subjects and templates** — `config.user.emailOverrides` overrides the subject and `templateName` for any of the five system emails: `invitation`, `resetPassword`, `resetPasswordNotification`, `emailVerification`, `duplicateEmail`. -61. **`sendEmail(options)` utility** — sends a templated email via `fastify.mailer`; accepts `{ fastify, subject, templateName, to, templateData }`. +62. **`sendEmail(options)` utility** — sends a templated email via `fastify.mailer`; accepts `{ fastify, subject, templateName, to, templateData }`. -62. **`verifyEmail(userId, email)` utility** — programmatically marks a user's email as verified in SuperTokens (useful for invited users who skip the verification link). +63. **`verifyEmail(userId, email)` utility** — programmatically marks a user's email as verified in SuperTokens (useful for invited users who skip the verification link). ## Password -63. **`validatePassword(password, config)` utility** — validates password strength against `config.user.password` options. Returns `{ success: true }` or `{ success: false, message }` listing all failed requirements. +64. **`validatePassword(password, config)` utility** — validates password strength against `config.user.password` options. Returns `{ success: true }` or `{ success: false, message }` listing all failed requirements. -64. **Configurable strength thresholds** — `config.user.password` accepts `minLength` (default: `8`), `minLowercase`, `minUppercase`, `minNumbers`, `minSymbols` (all default to `0` unless configured), and scoring tuning fields (`pointsPerUnique`, `pointsPerRepeat`, `pointsForContaining*`). +65. **Configurable strength thresholds** — `config.user.password` accepts `minLength` (default: `8`), `minLowercase`, `minUppercase`, `minNumbers`, `minSymbols` (all default to `0` unless configured), and scoring tuning fields (`pointsPerUnique`, `pointsPerRepeat`, `pointsForContaining*`). ## Profile Validation Claim -65. **`ProfileValidationClaim` custom session claim** — a SuperTokens `SessionClaim` that checks whether required profile fields are populated. Re-fetched on every request. Enable via `config.user.features.profileValidation.enabled = true` and list required fields in `features.profileValidation.fields`. +66. **`ProfileValidationClaim` custom session claim** — a SuperTokens `SessionClaim` that checks whether required profile fields are populated. Re-fetched on every request. Enable via `config.user.features.profileValidation.enabled = true` and list required fields in `features.profileValidation.fields`. -66. **Grace period** — `config.user.features.profileValidation.gracePeriodInDays` allows users to access protected resources for N days after sign-up before the claim is enforced. After the grace period, requests fail with 403. +67. **Grace period** — `config.user.features.profileValidation.gracePeriodInDays` allows users to access protected resources for N days after sign-up before the claim is enforced. After the grace period, requests fail with 403. -67. **Per-route claim opt-out** — routes that must stay accessible regardless of profile completeness can bypass the claim via `verifySession({ overrideGlobalClaimValidators: () => [] })` (REST) or `@auth(profileValidation: false)` (GraphQL). +68. **Per-route claim opt-out** — routes that must stay accessible regardless of profile completeness can bypass the claim via `verifySession({ overrideGlobalClaimValidators: () => [] })` (REST) or `@auth(profileValidation: false)` (GraphQL). ## GraphQL Integration > Requires `config.graphql.enabled = true` and `@prefabs.tech/fastify-graphql`. -68. **MercuriusContext extended with `user` and `roles`** — `context.user: User | undefined` and `context.roles: string[] | undefined` are populated before each resolver via `plugin.updateContext`. +69. **MercuriusContext extended with `user` and `roles`** — `context.user: User | undefined` and `context.roles: string[] | undefined` are populated before each resolver via `plugin.updateContext`. -69. **`@auth` directive** — protects a field or mutation; checks (1) authenticated session, (2) non-disabled account, (3) email verified (if enabled, unless `emailVerification: false` is passed), (4) profile complete (if enabled, unless `profileValidation: false` is passed). +70. **`@auth` directive** — protects a field or mutation; checks (1) authenticated session, (2) non-disabled account, (3) email verified (if enabled, unless `emailVerification: false` is passed), (4) profile complete (if enabled, unless `profileValidation: false` is passed). -70. **`@hasPermission(permission)` directive** — enforces a named permission on a GraphQL field; SUPERADMIN bypasses automatically. +71. **`@hasPermission(permission)` directive** — enforces a named permission on a GraphQL field; SUPERADMIN bypasses automatically. -71. **User GraphQL types** — `User`, `Photo`, `Users` (paginated wrapper with `totalCount`, `filteredCount`, `data`). +72. **User GraphQL types** — `User`, `Photo`, `Users` (paginated wrapper with `totalCount`, `filteredCount`, `data`). -72. **User queries** — `canAdminSignUp`, `me`, `user(id)`, `users(limit, offset, filters, sort)`. +73. **User queries** — `canAdminSignUp`, `me`, `user(id)`, `users(limit, offset, filters, sort)`. -73. **User mutations** — `adminSignUp`, `changeEmail`, `changePassword`, `deleteMe`, `disableUser`, `enableUser`, `removePhoto`, `updateMe`, `uploadPhoto`. The `uploadPhoto` mutation requires the GraphQL upload transport from `@prefabs.tech/fastify-graphql` (registered by default when the graphql plugin is enabled; configured via its `uploads` option). +74. **User mutations** — `adminSignUp`, `changeEmail`, `changePassword`, `deleteMe`, `disableUser`, `enableUser`, `removePhoto`, `updateMe`, `uploadPhoto`. The `uploadPhoto` mutation requires the GraphQL upload transport from `@prefabs.tech/fastify-graphql` (registered by default when the graphql plugin is enabled; configured via its `uploads` option). -74. **Invitation GraphQL types and operations** — `Invitation` type; queries `getInvitationByToken`, `listInvitation`; mutations `acceptInvitation`, `createInvitation`, `deleteInvitation`, `resendInvitation`, `revokeInvitation`. +75. **Invitation GraphQL types and operations** — `Invitation` type; queries `getInvitationByToken`, `listInvitation`; mutations `acceptInvitation`, `createInvitation`, `deleteInvitation`, `resendInvitation`, `revokeInvitation`. -75. **Role GraphQL types and operations** — `Role` type; queries `roles`, `rolePermissions`; mutations `createRole`, `deleteRole`, `updateRolePermissions`. +76. **Role GraphQL types and operations** — `Role` type; queries `roles`, `rolePermissions`; mutations `createRole`, `deleteRole`, `updateRolePermissions`. -76. **`permissions` GraphQL query** — returns the configured permission strings. +77. **`permissions` GraphQL query** — returns the configured permission strings. -77. **`userSchema` merged schema export** — the complete SDL string combining all user, invitation, role, and permission type definitions; ready to pass to `mergeTypeDefs`. +78. **`userSchema` merged schema export** — the complete SDL string combining all user, invitation, role, and permission type definitions; ready to pass to `mergeTypeDefs`. -78. **Resolver exports** — `userResolver`, `invitationResolver`, `roleResolver`, `permissionResolver` are exported individually for spreading into a larger resolver map. +79. **Resolver exports** — `userResolver`, `invitationResolver`, `roleResolver`, `permissionResolver` are exported individually for spreading into a larger resolver map. diff --git a/packages/user/GUIDE.md b/packages/user/GUIDE.md index 6d365bd87..09964a8dc 100644 --- a/packages/user/GUIDE.md +++ b/packages/user/GUIDE.md @@ -250,6 +250,33 @@ user: { For `override.apis` and `override.functions`, provide a function `(originalImpl, fastify) => partialOverride`; only the keys you return are replaced. +### Contributing a SuperTokens recipe from another plugin + +SuperTokens allows exactly one global `supertokens.init()`, and this package performs it synchronously while it is being registered — its recipe list is fixed at that moment. A plugin that wants to add a recipe of its own registers a factory instead: + +```typescript +import { addSupertokensRecipe } from "@prefabs.tech/fastify-user"; +import FastifyPlugin from "fastify-plugin"; +import Passwordless from "supertokens-node/recipe/passwordless"; + +const myRecipePlugin = async (fastify) => { + addSupertokensRecipe(fastify, (fastify) => + Passwordless.init({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE" }), + ); +}; + +export default FastifyPlugin(myRecipePlugin); +``` + +Factories accumulate on the `fastify.supertokensRecipes` decorator and are drained by `getRecipeList` during `supertokens.init()`. **The contributing plugin must be registered before `@prefabs.tech/fastify-user`:** + +```typescript +await fastify.register(myRecipePlugin); +await fastify.register(userPlugin); +``` + +Registering it afterwards throws `SuperTokens is already initialised. Register SuperTokens recipe plugins before @prefabs.tech/fastify-user.` rather than silently dropping the recipe. `@prefabs.tech/fastify-passwordless` is built on this hook. + ### Third-party OAuth providers Configure Apple, Facebook, GitHub, and Google via `config.user.supertokens.providers`: @@ -377,7 +404,7 @@ Notes: ### Immutable field guard -Before every `PUT /me` update, `filterUserUpdateInput` silently drops any attempt to modify `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disabled`, `deletedAt`, and their `snake_case` equivalents. +Before every `PUT /me` update, `filterUserUpdateInput` silently drops any attempt to modify `id`, `email`, `roles`, `lastLoginAt`, `phoneNumber`, `signedUpAt`, `disabled`, `deletedAt`, and their `snake_case` equivalents. `phoneNumber` is written by the passwordless sign-up flow, not by profile edits. ### Profile photo constraints diff --git a/packages/user/package.json b/packages/user/package.json index 1779e3caa..abfd1fd2a 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -32,7 +32,6 @@ }, "dependencies": { "humps": "2.0.1", - "twilio": "6.0.0", "validator": "13.15.35" }, "devDependencies": { From 0db79a9192ce3cfe7665e3424fdc853dbd9be21b Mon Sep 17 00:00:00 2001 From: anvesh Date: Tue, 28 Jul 2026 14:28:02 +0545 Subject: [PATCH 29/37] chore: remove supertokens test --- .../__test__/recipeRegistry.spec.ts | 103 ------------------ 1 file changed, 103 deletions(-) delete mode 100644 packages/user/src/supertokens/__test__/recipeRegistry.spec.ts diff --git a/packages/user/src/supertokens/__test__/recipeRegistry.spec.ts b/packages/user/src/supertokens/__test__/recipeRegistry.spec.ts deleted file mode 100644 index 522562eb7..000000000 --- a/packages/user/src/supertokens/__test__/recipeRegistry.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { FastifyInstance } from "fastify"; - -/* istanbul ignore file */ -import Fastify from "fastify"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import addSupertokensRecipe from "../recipeRegistry"; - -// The individual recipe inits reach SuperTokens' global singleton; the registry -// itself is what is under test, so they are stubbed out. -vi.mock("../recipes/initSessionRecipe", () => ({ default: () => "session" })); -vi.mock("../recipes/initThirdPartyEmailPasswordRecipe", () => ({ - default: () => "thirdPartyEmailPassword", -})); -vi.mock("../recipes/initUserRolesRecipe", () => ({ - default: () => "userRoles", -})); -vi.mock("../recipes/initEmailVerificationRecipe", () => ({ - default: () => "emailVerification", -})); - -const { default: getRecipeList } = await import("../recipes"); - -const buildFastify = (): FastifyInstance => { - const fastify = Fastify({ logger: false }); - - fastify.decorate("config", { user: { supertokens: {} } }); - - return fastify; -}; - -describe("addSupertokensRecipe", () => { - let fastify: FastifyInstance; - - afterEach(async () => { - await fastify.close(); - }); - - it("creates the registry on first use", () => { - fastify = buildFastify(); - - addSupertokensRecipe(fastify, () => "passwordless"); - - expect(fastify.supertokensRecipes).toHaveLength(1); - }); - - it("appends to an existing registry", () => { - fastify = buildFastify(); - - addSupertokensRecipe(fastify, () => "one"); - addSupertokensRecipe(fastify, () => "two"); - - expect(fastify.supertokensRecipes).toHaveLength(2); - }); - - it("throws when SuperTokens has already been initialised", () => { - fastify = buildFastify(); - fastify.decorate("supertokensInitialized", true); - - expect(() => addSupertokensRecipe(fastify, () => "late")).toThrow( - /Register SuperTokens recipe plugins before @prefabs.tech\/fastify-user/, - ); - }); -}); - -describe("getRecipeList", () => { - let fastify: FastifyInstance; - - afterEach(async () => { - await fastify.close(); - }); - - it("returns the always-on recipes when the registry is empty", () => { - fastify = buildFastify(); - - expect(getRecipeList(fastify)).toStrictEqual([ - "session", - "thirdPartyEmailPassword", - "userRoles", - ]); - }); - - it("drains registered recipe factories", () => { - fastify = buildFastify(); - - addSupertokensRecipe(fastify, () => "passwordless"); - - expect(getRecipeList(fastify)).toContain("passwordless"); - }); - - it("passes the fastify instance to each registered factory", () => { - fastify = buildFastify(); - - const factory = vi.fn(() => "passwordless"); - - addSupertokensRecipe(fastify, factory); - getRecipeList(fastify); - - // Identity check rather than toHaveBeenCalledWith: deep-equalling a Fastify - // instance touches getters that throw before the server is listening. - expect(factory.mock.calls[0][0]).toBe(fastify); - }); -}); From 330509001738526bc16868dbf1019ba330fc3956 Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 14:29:52 +0545 Subject: [PATCH 30/37] feat(passwordless): add migration to add phone_number to users table --- packages/passwordless/src/migrations/queries.ts | 16 ++++++++++++++++ .../passwordless/src/migrations/runMigrations.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/passwordless/src/migrations/queries.ts create mode 100644 packages/passwordless/src/migrations/runMigrations.ts diff --git a/packages/passwordless/src/migrations/queries.ts b/packages/passwordless/src/migrations/queries.ts new file mode 100644 index 000000000..b87ba851f --- /dev/null +++ b/packages/passwordless/src/migrations/queries.ts @@ -0,0 +1,16 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { QuerySqlToken } from "slonik"; + +import { TABLE_USERS } from "@prefabs.tech/fastify-user"; +import { sql } from "slonik"; + +const addPhoneNumberInUsersTableQuery = (config: ApiConfig): QuerySqlToken => { + const users = config.user.tables?.users?.name || TABLE_USERS; + + return sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 ); + `; +}; + +export { addPhoneNumberInUsersTableQuery }; diff --git a/packages/passwordless/src/migrations/runMigrations.ts b/packages/passwordless/src/migrations/runMigrations.ts new file mode 100644 index 000000000..ab813dd8c --- /dev/null +++ b/packages/passwordless/src/migrations/runMigrations.ts @@ -0,0 +1,12 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +import { addPhoneNumberInUsersTableQuery } from "./queries"; + +const runMigrations = async (config: ApiConfig, database: Database) => { + await database.connect(async (connection) => { + await connection.query(addPhoneNumberInUsersTableQuery(config)); + }); +}; + +export default runMigrations; From d3273bc1d138ea1a3f601762609c322227f7065e Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 14:31:22 +0545 Subject: [PATCH 31/37] fix(passwordless): defer migration to onReady hook --- .../passwordless/src/__test__/plugin.test.ts | 32 ++++++++++++++++++- packages/passwordless/src/plugin.ts | 9 ++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/passwordless/src/__test__/plugin.test.ts b/packages/passwordless/src/__test__/plugin.test.ts index 9ab2fc090..f4dfaa5c1 100644 --- a/packages/passwordless/src/__test__/plugin.test.ts +++ b/packages/passwordless/src/__test__/plugin.test.ts @@ -2,10 +2,15 @@ import type { FastifyInstance } from "fastify"; /* istanbul ignore file */ import Fastify from "fastify"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import runMigrations from "../migrations/runMigrations"; import plugin from "../plugin"; +vi.mock("../migrations/runMigrations", () => ({ + default: vi.fn(), +})); + /** * Builds a Fastify instance decorated with everything the passwordless plugin * reads. `addSupertokensRecipe` comes from @prefabs.tech/fastify-user and only @@ -21,12 +26,18 @@ const buildFastify = ( passwordless: passwordlessConfig, }); + fastify.decorate("slonik", {}); + return fastify; }; describe("passwordlessPlugin", () => { let fastify: FastifyInstance; + beforeEach(() => { + vi.mocked(runMigrations).mockClear(); + }); + afterEach(async () => { await fastify.close(); }); @@ -59,6 +70,25 @@ describe("passwordlessPlugin", () => { expect(fastify.supertokensRecipes).toBeUndefined(); }); + it("runs the migration on ready, not during registration", async () => { + fastify = buildFastify({}); + await fastify.register(plugin); + + expect(runMigrations).not.toHaveBeenCalled(); + + await fastify.ready(); + + expect(runMigrations).toHaveBeenCalledWith(fastify.config, fastify.slonik); + }); + + it("runs no migration when enabled === false", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrations).not.toHaveBeenCalled(); + }); + it("throws when registered after SuperTokens has already been initialised", async () => { fastify = buildFastify({}); fastify.decorate("supertokensInitialized", true); diff --git a/packages/passwordless/src/plugin.ts b/packages/passwordless/src/plugin.ts index 1a7bd16c1..63b1724de 100644 --- a/packages/passwordless/src/plugin.ts +++ b/packages/passwordless/src/plugin.ts @@ -3,6 +3,7 @@ import type { FastifyPluginAsync } from "fastify"; import { addSupertokensRecipe } from "@prefabs.tech/fastify-user"; import FastifyPlugin from "fastify-plugin"; +import runMigrations from "./migrations/runMigrations"; import initPasswordlessRecipe from "./recipe/initPasswordlessRecipe"; const passwordlessPlugin: FastifyPluginAsync = async (fastify) => { @@ -15,6 +16,14 @@ const passwordlessPlugin: FastifyPluginAsync = async (fastify) => { fastify.log.info("Registering fastify-passwordless plugin"); addSupertokensRecipe(fastify, initPasswordlessRecipe); + + // The migration alters the users table, which @prefabs.tech/fastify-user + // creates during its own registration — and this plugin has to be registered + // BEFORE that one (see addSupertokensRecipe). onReady is the only point where + // the table is guaranteed to exist. + fastify.addHook("onReady", async () => { + await runMigrations(fastify.config, fastify.slonik); + }); }; export default FastifyPlugin(passwordlessPlugin); From 9624a412463e06676df8982b3d134c8856e8dc78 Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 14:32:34 +0545 Subject: [PATCH 32/37] feat(passwordless): augment fastify-user User with phoneNumber --- packages/passwordless/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/passwordless/src/index.ts b/packages/passwordless/src/index.ts index b8ebb8c8c..78d3fb256 100644 --- a/packages/passwordless/src/index.ts +++ b/packages/passwordless/src/index.ts @@ -6,6 +6,12 @@ declare module "@prefabs.tech/fastify-config" { } } +declare module "@prefabs.tech/fastify-user" { + interface User { + phoneNumber?: string; + } +} + export * from "./constants"; export { default as getTwilioClient } from "./lib/getTwilioClient"; From 75f9a817378749da060bf7aa9b962a4a390c0637 Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 14:33:04 +0545 Subject: [PATCH 33/37] docs: update docs --- packages/passwordless/FEATURES.md | 54 +++++++++++++++++-------------- packages/passwordless/GUIDE.md | 12 ++++++- packages/passwordless/README.md | 1 + 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/passwordless/FEATURES.md b/packages/passwordless/FEATURES.md index dfc3b2bff..b334ee8d9 100644 --- a/packages/passwordless/FEATURES.md +++ b/packages/passwordless/FEATURES.md @@ -10,60 +10,64 @@ 3. **Registration order guard** — `addSupertokensRecipe` throws when the Fastify instance already carries the `supertokensInitialized` decorator, i.e. when this plugin is registered *after* `@prefabs.tech/fastify-user`. SuperTokens allows exactly one global `init()`, so a late registration could not contribute a recipe; failing loudly beats silently dropping passwordless login. -4. **No routes of its own** — this package registers no controllers. The passwordless endpoints are served by the SuperTokens Fastify plugin that `@prefabs.tech/fastify-user` registers. +4. **Phone number migration** — on `onReady` the plugin runs an idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 )` against `config.user.tables?.users?.name` (default `users`). It runs on `onReady` rather than at registration because the users table is created while `@prefabs.tech/fastify-user` registers, and this plugin must be registered before that one. No migration runs when `enabled === false`. + +5. **`User` type augmentation** — the package augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, which also flows into `UserCreateInput`. Types only: `@prefabs.tech/fastify-user` does not carry the field in its REST response schema or GraphQL SDL, so it is not serialized on those endpoints. + +6. **No routes of its own** — this package registers no controllers. The passwordless endpoints are served by the SuperTokens Fastify plugin that `@prefabs.tech/fastify-user` registers. ## Recipe Configuration -5. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.passwordless`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. +7. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.passwordless`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. -6. **Full recipe escape hatch** — when `config.passwordless.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. +8. **Full recipe escape hatch** — when `config.passwordless.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. -7. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.passwordless` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. +9. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.passwordless` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. -8. **API override wrappers** — each entry in `config.passwordless.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. +10. **API override wrappers** — each entry in `config.passwordless.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. -9. **Function override wrappers** — same mechanism for `config.passwordless.override.functions` over the built-in `consumeCode` override. +11. **Function override wrappers** — same mechanism for `config.passwordless.override.functions` over the built-in `consumeCode` override. ## Twilio Verify Integration -10. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP. +12. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP. -11. **Dev mode OTP** — when `config.passwordless.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. +13. **Dev mode OTP** — when `config.passwordless.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. -12. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.passwordless.bypassSmsFor` also get `devModeOtp` and no SMS is sent. +14. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.passwordless.bypassSmsFor` also get `devModeOtp` and no SMS is sent. -13. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown. +15. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown. -14. **Dev mode skips SMS delivery entirely** — in dev mode the recipe supplies `createAndSendCustomTextMessage` (a log line) instead of `smsDelivery`. +16. **Dev mode skips SMS delivery entirely** — in dev mode the recipe supplies `createAndSendCustomTextMessage` (a log line) instead of `smsDelivery`. -15. **Phone number capture on create** — the `createCodePOST` override copies `input.phoneNumber` onto `input.userContext` so downstream hooks can read it. +17. **Phone number capture on create** — the `createCodePOST` override copies `input.phoneNumber` onto `input.userContext` so downstream hooks can read it. -16. **OTP verification on consume** — the `consumeCodePOST` override looks the device up by `preAuthSessionId`, then calls `verify.v2.services(verifyServiceSid).verificationChecks.create({ code, to })`. On `approved` it replays the original `consumeCodePOST` with the placeholder code; otherwise it returns `INCORRECT_USER_INPUT_CODE_ERROR`. +18. **OTP verification on consume** — the `consumeCodePOST` override looks the device up by `preAuthSessionId`, then calls `verify.v2.services(verifyServiceSid).verificationChecks.create({ code, to })`. On `approved` it replays the original `consumeCodePOST` with the placeholder code; otherwise it returns `INCORRECT_USER_INPUT_CODE_ERROR`. -17. **Graceful degradation to RESTART_FLOW_ERROR** — a missing device/phone number, unusable Twilio credentials, or a thrown Twilio Verify call all return `{ status: "RESTART_FLOW_ERROR" }` after logging. +19. **Graceful degradation to RESTART_FLOW_ERROR** — a missing device/phone number, unusable Twilio credentials, or a thrown Twilio Verify call all return `{ status: "RESTART_FLOW_ERROR" }` after logging. -18. **Dev mode and bypassed numbers skip Twilio on consume** — they go straight to the original `consumeCodePOST`, which validates against `devModeOtp`. +20. **Dev mode and bypassed numbers skip Twilio on consume** — they go straight to the original `consumeCodePOST`, which validates against `devModeOtp`. -19. **Magic link flows pass through untouched** — when `input` carries no `userInputCode`, `consumeCodePOST` delegates to the original implementation without contacting Twilio. +21. **Magic link flows pass through untouched** — when `input` carries no `userInputCode`, `consumeCodePOST` delegates to the original implementation without contacting Twilio. -20. **Synthetic email enrichment** — successful consume responses get `email` filled in as `@` when SuperTokens has none. +22. **Synthetic email enrichment** — successful consume responses get `email` filled in as `@` when SuperTokens has none. ## Local User Creation -21. **Role existence check before signup** — `functions.consumeCode` verifies every role in `userContext.roles` (default `[config.user.role ?? ROLE_USER]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise. +23. **Role existence check before signup** — `functions.consumeCode` verifies every role in `userContext.roles` (default `[config.user.role ?? ROLE_USER]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise. -22. **Local user row on first sign-in** — when SuperTokens reports `createdNewUser`, a row is created through `getUserService` with the id, phone number, and synthetic email. The email domain falls back to the app name lowercased with whitespace stripped plus `.com`. +24. **Local user row on first sign-in** — when SuperTokens reports `createdNewUser`, a row is created through `getUserService` with the id, phone number, and synthetic email. The email domain falls back to the app name lowercased with whitespace stripped plus `.com`. -23. **Rollback on failed insert** — if the local insert throws, the SuperTokens user is deleted via `deleteUser` before the error is rethrown, so the two stores cannot drift. +25. **Rollback on failed insert** — if the local insert throws, the SuperTokens user is deleted via `deleteUser` before the error is rethrown, so the two stores cannot drift. -24. **Missing phone number aborts signup** — when neither a phone number nor an email is available the SuperTokens user is deleted and an error is thrown. +26. **Missing phone number aborts signup** — when neither a phone number nor an email is available the SuperTokens user is deleted and an error is thrown. -25. **Role assignment** — each role is assigned with `UserRoles.addRoleToUser`; a non-`OK` status is logged rather than thrown. +27. **Role assignment** — each role is assigned with `UserRoles.addRoleToUser`; a non-`OK` status is logged rather than thrown. -26. **`lastLoginAt` refresh on returning users** — when no new user was created, `lastLoginAt` is updated; a failure is logged and swallowed so sign-in still succeeds. +28. **`lastLoginAt` refresh on returning users** — when no new user was created, `lastLoginAt` is updated; a failure is logged and swallowed so sign-in still succeeds. -27. **Multi-tenant request context** — the user service is built from the request recovered via `getRequestFromUserContext`, so `request.config`, `request.slonik` and `request.dbSchema` win over the Fastify-level ones when present. +29. **Multi-tenant request context** — the user service is built from the request recovered via `getRequestFromUserContext`, so `request.config`, `request.slonik` and `request.dbSchema` win over the Fastify-level ones when present. ## Known Limitations -28. **`bypassSmsFor` does not apply on resend** — `resendCodePOST` is not overridden and `userContext.phoneNumber` is only set by `createCodePOST`, so `getCustomUserInputCode` cannot match a bypassed number on the resend path. +30. **`bypassSmsFor` does not apply on resend** — `resendCodePOST` is not overridden and `userContext.phoneNumber` is only set by `createCodePOST`, so `getCustomUserInputCode` cannot match a bypassed number on the resend path. diff --git a/packages/passwordless/GUIDE.md b/packages/passwordless/GUIDE.md index fd8ea8dac..11cf37599 100644 --- a/packages/passwordless/GUIDE.md +++ b/packages/passwordless/GUIDE.md @@ -117,7 +117,17 @@ On first successful sign-in, `functions.consumeCode`: On subsequent sign-ins it only updates `lastLoginAt`. -The `phone_number` column and the `phoneNumber` field on `User` are provided by `@prefabs.tech/fastify-user`; its migrations add the column automatically. +## Migration + +This package owns the `phone_number` column. On `onReady` it runs an idempotent + +```sql +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 ); +``` + +against `config.user.tables?.users?.name` (default `users`). It runs on `onReady`, not at registration time, because the table is created while `@prefabs.tech/fastify-user` registers — and this plugin has to be registered *before* that one. + +It also augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, so the field is typed wherever `User`, `UserCreateInput`, or `request.user` is used in an app that registers this plugin. The augmentation is types only: `@prefabs.tech/fastify-user` does not list `phoneNumber` in its REST response schema or GraphQL SDL, so the column is not serialized on those endpoints unless you extend those schemas in your app. ## Configuration reference diff --git a/packages/passwordless/README.md b/packages/passwordless/README.md index f8eb8db2b..f9e82ce0f 100644 --- a/packages/passwordless/README.md +++ b/packages/passwordless/README.md @@ -10,6 +10,7 @@ SuperTokens ships a Passwordless recipe, but wiring it to Twilio Verify and to y - **Keep the auth package lean**: passwordless is opt-in. Apps that do not use it never install `twilio`, and `@prefabs.tech/fastify-user` carries no passwordless config surface. - **Initialise the recipe automatically**: registering this plugin is all it takes — the SuperTokens Passwordless recipe is contributed to `@prefabs.tech/fastify-user`'s recipe list for you. - **Create the local user row**: on first sign-in a matching row is created in your `users` table with the phone number and a synthetic `@` email, since SuperTokens requires an email. +- **Own the `phone_number` column**: an idempotent migration adds it to the users table, and the `User` type from `@prefabs.tech/fastify-user` is augmented with `phoneNumber?: string`. - **Support local development without Twilio**: a dev mode and a per-number bypass list accept a fixed OTP so you can develop and test without sending real SMS. ## Requirements From 541a13f03fa419c69439bdcce63f86f8818fb351 Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 14:34:33 +0545 Subject: [PATCH 34/37] feat: remove phone number from user type and schema --- packages/user/FEATURES.md | 4 ++-- packages/user/GUIDE.md | 2 +- packages/user/src/migrations/queries.ts | 12 ------------ packages/user/src/migrations/runMigrations.ts | 4 ---- .../__test__/filterUserUpdateInput.spec.ts | 18 ------------------ .../src/model/users/filterUserUpdateInput.ts | 1 - .../user/src/model/users/graphql/schema.ts | 1 - packages/user/src/model/users/schema.ts | 1 - packages/user/src/types/user.ts | 2 -- 9 files changed, 3 insertions(+), 42 deletions(-) diff --git a/packages/user/FEATURES.md b/packages/user/FEATURES.md index 2bd2d75f2..ff29302dc 100644 --- a/packages/user/FEATURES.md +++ b/packages/user/FEATURES.md @@ -8,7 +8,7 @@ 2. **Selective route module disabling** — each of the four route groups (`users`, `invitations`, `roles`, `permissions`) can be disabled independently via `routes..disabled = true`. The service layer is unaffected. -3. **Automatic database migrations** — on registration, runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables before the server is ready, plus an idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number` on the users table. The matching `phoneNumber?: string` field is present on `User` and `UserCreateInput`, exposed on the REST and GraphQL `User` types, and omitted from `UserUpdateInput` — it is written by the sign-up flow (see `@prefabs.tech/fastify-passwordless`), not by profile edits. +3. **Automatic database migrations** — on registration, runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables before the server is ready. 4. **Default role seeding** — on `onReady`, seeds `ADMIN`, `SUPERADMIN`, and `USER` into SuperTokens, plus any extra roles listed in `config.user.roles`. @@ -73,7 +73,7 @@ 30. **`PUT /users/:id/enable`** — clears the user's `disabled` flag. Requires `users:enable` permission. -31. **Immutable field guard (`filterUserUpdateInput`)** — applied automatically before every profile update; silently drops any attempt to set `id`, `email`, `roles`, `lastLoginAt`, `phoneNumber`, `signedUpAt`, `disable`, or `enable`. Handles both camelCase and snake_case variants (e.g. `last_login_at` is also stripped). +31. **Immutable field guard (`filterUserUpdateInput`)** — applied automatically before every profile update; silently drops any attempt to set `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disable`, or `enable`. Handles both camelCase and snake_case variants (e.g. `last_login_at` is also stripped). 32. **Configurable table names** — `config.user.tables.users.name` and `config.user.tables.invitations.name` override the default table names. diff --git a/packages/user/GUIDE.md b/packages/user/GUIDE.md index 09964a8dc..bfe3048f9 100644 --- a/packages/user/GUIDE.md +++ b/packages/user/GUIDE.md @@ -404,7 +404,7 @@ Notes: ### Immutable field guard -Before every `PUT /me` update, `filterUserUpdateInput` silently drops any attempt to modify `id`, `email`, `roles`, `lastLoginAt`, `phoneNumber`, `signedUpAt`, `disabled`, `deletedAt`, and their `snake_case` equivalents. `phoneNumber` is written by the passwordless sign-up flow, not by profile edits. +Before every `PUT /me` update, `filterUserUpdateInput` silently drops any attempt to modify `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disabled`, `deletedAt`, and their `snake_case` equivalents. ### Profile photo constraints diff --git a/packages/user/src/migrations/queries.ts b/packages/user/src/migrations/queries.ts index 01aedd258..04064b019 100644 --- a/packages/user/src/migrations/queries.ts +++ b/packages/user/src/migrations/queries.ts @@ -14,17 +14,6 @@ import { TABLE_USERS, } from "../constants"; -const addPhoneNumberInUsersTableQuery = ( - config: ApiConfig, -): QuerySqlToken => { - const users = config.user.tables?.users?.name || TABLE_USERS; - - return sql.unsafe` - ALTER TABLE ${sql.identifier([users])} - ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 ); - `; -}; - const addProfileInUsersTableQuery = ( config: ApiConfig, ): QuerySqlToken => { @@ -170,7 +159,6 @@ const createProfileFieldsTablesQueries = ( }; export { - addPhoneNumberInUsersTableQuery, addProfileInUsersTableQuery, createInvitationsTableQuery, createProfileFieldsTablesQueries, diff --git a/packages/user/src/migrations/runMigrations.ts b/packages/user/src/migrations/runMigrations.ts index a66b9c414..97e28040f 100644 --- a/packages/user/src/migrations/runMigrations.ts +++ b/packages/user/src/migrations/runMigrations.ts @@ -2,7 +2,6 @@ import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; import { - addPhoneNumberInUsersTableQuery, addProfileInUsersTableQuery, createInvitationsTableQuery, createProfileFieldsTablesQueries, @@ -13,9 +12,6 @@ const runMigrations = async (config: ApiConfig, database: Database) => { await database.connect(async (connection) => { await connection.transaction(async (transactionConnection) => { await transactionConnection.query(createUsersTableQuery(config)); - await transactionConnection.query( - addPhoneNumberInUsersTableQuery(config), - ); await transactionConnection.query(createInvitationsTableQuery(config)); if (config.user.features?.profileFields?.enabled) { diff --git a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts index d62ecc3d3..08d050ab8 100644 --- a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts +++ b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts @@ -32,24 +32,6 @@ describe("filterUserUpdateInput", () => { expect(updateInput).not.toHaveProperty("id"); }); - it("removes camelCase 'phoneNumber'", () => { - const updateInput = { phoneNumber: "+15550001111" } as UserUpdateInput; - - filterUserUpdateInput(updateInput); - - expect(updateInput).not.toHaveProperty("phoneNumber"); - }); - - it("removes snake_case 'phone_number' (camelized to phoneNumber)", () => { - const updateInput = { - phone_number: "+15550001111", - } as unknown as UserUpdateInput; - - filterUserUpdateInput(updateInput); - - expect(updateInput).not.toHaveProperty("phone_number"); - }); - it("removes 'roles'", () => { const updateInput = { roles: ["ADMIN"] } as UserUpdateInput; diff --git a/packages/user/src/model/users/filterUserUpdateInput.ts b/packages/user/src/model/users/filterUserUpdateInput.ts index baf1c2dc9..44861ca3b 100644 --- a/packages/user/src/model/users/filterUserUpdateInput.ts +++ b/packages/user/src/model/users/filterUserUpdateInput.ts @@ -8,7 +8,6 @@ const ignoredUpdateKeys = new Set([ "enable", "id", "lastLoginAt", - "phoneNumber", "roles", "signedUpAt", ]) as Set; diff --git a/packages/user/src/model/users/graphql/schema.ts b/packages/user/src/model/users/graphql/schema.ts index 998261d19..a225cb20f 100644 --- a/packages/user/src/model/users/graphql/schema.ts +++ b/packages/user/src/model/users/graphql/schema.ts @@ -9,7 +9,6 @@ const user = gql` disabled: Boolean! email: String! lastLoginAt: Float! - phoneNumber: String photoId: Int photo: Photo roles: [String] diff --git a/packages/user/src/model/users/schema.ts b/packages/user/src/model/users/schema.ts index f639a7e96..630204424 100644 --- a/packages/user/src/model/users/schema.ts +++ b/packages/user/src/model/users/schema.ts @@ -6,7 +6,6 @@ export const userSchema = { email: { format: "email", type: "string" }, id: { type: "string" }, lastLoginAt: { type: "number" }, - phoneNumber: { nullable: true, type: "string" }, photoId: { nullable: true, type: "number" }, roles: { items: { type: "string" }, type: "array" }, signedUpAt: { type: "number" }, diff --git a/packages/user/src/types/user.ts b/packages/user/src/types/user.ts index 2e7dcee5b..0ab89ced8 100644 --- a/packages/user/src/types/user.ts +++ b/packages/user/src/types/user.ts @@ -14,7 +14,6 @@ interface User { email: string; id: string; lastLoginAt: number; - phoneNumber?: string; photo?: Photo; photoId?: null | number; profile?: { [key: string]: boolean | null | number | string }; @@ -39,7 +38,6 @@ type UserUpdateInput = Partial< | "email" | "id" | "lastLoginAt" - | "phoneNumber" | "photo" | "profile" | "roles" From ccd463ee8d6d22deb8b73197c7f98078b0f24f8c Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 15:29:01 +0545 Subject: [PATCH 35/37] feat(passwordless): expose phoneNumber on GraphQL User type --- packages/passwordless/FEATURES.md | 54 ++++++------- packages/passwordless/GUIDE.md | 23 +++++- packages/passwordless/README.md | 2 +- .../src/__test__/extendUserSchema.test.ts | 77 +++++++++++++++++++ .../src/graphql/extendUserSchema.ts | 21 +++++ packages/passwordless/src/plugin.ts | 2 + 6 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 packages/passwordless/src/__test__/extendUserSchema.test.ts create mode 100644 packages/passwordless/src/graphql/extendUserSchema.ts diff --git a/packages/passwordless/FEATURES.md b/packages/passwordless/FEATURES.md index b334ee8d9..f99119c00 100644 --- a/packages/passwordless/FEATURES.md +++ b/packages/passwordless/FEATURES.md @@ -12,62 +12,64 @@ 4. **Phone number migration** — on `onReady` the plugin runs an idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 )` against `config.user.tables?.users?.name` (default `users`). It runs on `onReady` rather than at registration because the users table is created while `@prefabs.tech/fastify-user` registers, and this plugin must be registered before that one. No migration runs when `enabled === false`. -5. **`User` type augmentation** — the package augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, which also flows into `UserCreateInput`. Types only: `@prefabs.tech/fastify-user` does not carry the field in its REST response schema or GraphQL SDL, so it is not serialized on those endpoints. +5. **`User` type augmentation** — the package augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, which also flows into `UserCreateInput`. `@prefabs.tech/fastify-user` does not carry the field in its REST response schema, so it is not serialized on the users REST routes; GraphQL exposure is handled at runtime, below. -6. **No routes of its own** — this package registers no controllers. The passwordless endpoints are served by the SuperTokens Fastify plugin that `@prefabs.tech/fastify-user` registers. +6. **Runtime GraphQL `User` extension** — in the same `onReady` hook, the plugin calls `fastify.graphql.extendSchema("extend type User { phoneNumber: String }")`, so the field appears on the GraphQL `User` type with no consumer wiring. It is guarded by `fastify.graphql?.schema?.getType("User")`: apps without GraphQL enabled, or that never merged `userSchema`, are skipped rather than failed. No resolver is required — the default field resolver reads the camelized `phoneNumber` off the row. + +7. **No routes of its own** — this package registers no controllers. The passwordless endpoints are served by the SuperTokens Fastify plugin that `@prefabs.tech/fastify-user` registers. ## Recipe Configuration -7. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.passwordless`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. +8. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.passwordless`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. -8. **Full recipe escape hatch** — when `config.passwordless.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. +9. **Full recipe escape hatch** — when `config.passwordless.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. -9. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.passwordless` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. +10. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.passwordless` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. -10. **API override wrappers** — each entry in `config.passwordless.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. +11. **API override wrappers** — each entry in `config.passwordless.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. -11. **Function override wrappers** — same mechanism for `config.passwordless.override.functions` over the built-in `consumeCode` override. +12. **Function override wrappers** — same mechanism for `config.passwordless.override.functions` over the built-in `consumeCode` override. ## Twilio Verify Integration -12. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP. +13. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP. -13. **Dev mode OTP** — when `config.passwordless.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. +14. **Dev mode OTP** — when `config.passwordless.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. -14. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.passwordless.bypassSmsFor` also get `devModeOtp` and no SMS is sent. +15. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.passwordless.bypassSmsFor` also get `devModeOtp` and no SMS is sent. -15. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown. +16. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown. -16. **Dev mode skips SMS delivery entirely** — in dev mode the recipe supplies `createAndSendCustomTextMessage` (a log line) instead of `smsDelivery`. +17. **Dev mode skips SMS delivery entirely** — in dev mode the recipe supplies `createAndSendCustomTextMessage` (a log line) instead of `smsDelivery`. -17. **Phone number capture on create** — the `createCodePOST` override copies `input.phoneNumber` onto `input.userContext` so downstream hooks can read it. +18. **Phone number capture on create** — the `createCodePOST` override copies `input.phoneNumber` onto `input.userContext` so downstream hooks can read it. -18. **OTP verification on consume** — the `consumeCodePOST` override looks the device up by `preAuthSessionId`, then calls `verify.v2.services(verifyServiceSid).verificationChecks.create({ code, to })`. On `approved` it replays the original `consumeCodePOST` with the placeholder code; otherwise it returns `INCORRECT_USER_INPUT_CODE_ERROR`. +19. **OTP verification on consume** — the `consumeCodePOST` override looks the device up by `preAuthSessionId`, then calls `verify.v2.services(verifyServiceSid).verificationChecks.create({ code, to })`. On `approved` it replays the original `consumeCodePOST` with the placeholder code; otherwise it returns `INCORRECT_USER_INPUT_CODE_ERROR`. -19. **Graceful degradation to RESTART_FLOW_ERROR** — a missing device/phone number, unusable Twilio credentials, or a thrown Twilio Verify call all return `{ status: "RESTART_FLOW_ERROR" }` after logging. +20. **Graceful degradation to RESTART_FLOW_ERROR** — a missing device/phone number, unusable Twilio credentials, or a thrown Twilio Verify call all return `{ status: "RESTART_FLOW_ERROR" }` after logging. -20. **Dev mode and bypassed numbers skip Twilio on consume** — they go straight to the original `consumeCodePOST`, which validates against `devModeOtp`. +21. **Dev mode and bypassed numbers skip Twilio on consume** — they go straight to the original `consumeCodePOST`, which validates against `devModeOtp`. -21. **Magic link flows pass through untouched** — when `input` carries no `userInputCode`, `consumeCodePOST` delegates to the original implementation without contacting Twilio. +22. **Magic link flows pass through untouched** — when `input` carries no `userInputCode`, `consumeCodePOST` delegates to the original implementation without contacting Twilio. -22. **Synthetic email enrichment** — successful consume responses get `email` filled in as `@` when SuperTokens has none. +23. **Synthetic email enrichment** — successful consume responses get `email` filled in as `@` when SuperTokens has none. ## Local User Creation -23. **Role existence check before signup** — `functions.consumeCode` verifies every role in `userContext.roles` (default `[config.user.role ?? ROLE_USER]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise. +24. **Role existence check before signup** — `functions.consumeCode` verifies every role in `userContext.roles` (default `[config.user.role ?? ROLE_USER]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise. -24. **Local user row on first sign-in** — when SuperTokens reports `createdNewUser`, a row is created through `getUserService` with the id, phone number, and synthetic email. The email domain falls back to the app name lowercased with whitespace stripped plus `.com`. +25. **Local user row on first sign-in** — when SuperTokens reports `createdNewUser`, a row is created through `getUserService` with the id, phone number, and synthetic email. The email domain falls back to the app name lowercased with whitespace stripped plus `.com`. -25. **Rollback on failed insert** — if the local insert throws, the SuperTokens user is deleted via `deleteUser` before the error is rethrown, so the two stores cannot drift. +26. **Rollback on failed insert** — if the local insert throws, the SuperTokens user is deleted via `deleteUser` before the error is rethrown, so the two stores cannot drift. -26. **Missing phone number aborts signup** — when neither a phone number nor an email is available the SuperTokens user is deleted and an error is thrown. +27. **Missing phone number aborts signup** — when neither a phone number nor an email is available the SuperTokens user is deleted and an error is thrown. -27. **Role assignment** — each role is assigned with `UserRoles.addRoleToUser`; a non-`OK` status is logged rather than thrown. +28. **Role assignment** — each role is assigned with `UserRoles.addRoleToUser`; a non-`OK` status is logged rather than thrown. -28. **`lastLoginAt` refresh on returning users** — when no new user was created, `lastLoginAt` is updated; a failure is logged and swallowed so sign-in still succeeds. +29. **`lastLoginAt` refresh on returning users** — when no new user was created, `lastLoginAt` is updated; a failure is logged and swallowed so sign-in still succeeds. -29. **Multi-tenant request context** — the user service is built from the request recovered via `getRequestFromUserContext`, so `request.config`, `request.slonik` and `request.dbSchema` win over the Fastify-level ones when present. +30. **Multi-tenant request context** — the user service is built from the request recovered via `getRequestFromUserContext`, so `request.config`, `request.slonik` and `request.dbSchema` win over the Fastify-level ones when present. ## Known Limitations -30. **`bypassSmsFor` does not apply on resend** — `resendCodePOST` is not overridden and `userContext.phoneNumber` is only set by `createCodePOST`, so `getCustomUserInputCode` cannot match a bypassed number on the resend path. +31. **`bypassSmsFor` does not apply on resend** — `resendCodePOST` is not overridden and `userContext.phoneNumber` is only set by `createCodePOST`, so `getCustomUserInputCode` cannot match a bypassed number on the resend path. diff --git a/packages/passwordless/GUIDE.md b/packages/passwordless/GUIDE.md index 11cf37599..8d5ce9db6 100644 --- a/packages/passwordless/GUIDE.md +++ b/packages/passwordless/GUIDE.md @@ -127,7 +127,28 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 ); against `config.user.tables?.users?.name` (default `users`). It runs on `onReady`, not at registration time, because the table is created while `@prefabs.tech/fastify-user` registers — and this plugin has to be registered *before* that one. -It also augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, so the field is typed wherever `User`, `UserCreateInput`, or `request.user` is used in an app that registers this plugin. The augmentation is types only: `@prefabs.tech/fastify-user` does not list `phoneNumber` in its REST response schema or GraphQL SDL, so the column is not serialized on those endpoints unless you extend those schemas in your app. +It also augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, so the field is typed wherever `User`, `UserCreateInput`, or `request.user` is used in an app that registers this plugin. + +## GraphQL + +`@prefabs.tech/fastify-user` does not carry `phoneNumber` in its `User` SDL, so this plugin adds it at runtime. In the same `onReady` hook as the migration it calls: + +```typescript +fastify.graphql.extendSchema(` + extend type User { + phoneNumber: String + } +`); +``` + +No consumer wiring is required — merge `userSchema` as you normally would and the field appears on the `User` type. + +Details: + +- **No resolver is needed.** The default field resolver reads `phoneNumber` off the row, which the slonik interceptor camelizes from `phone_number`; the user service selects `users.*`, so the value is already there. +- **It is skipped, not failed, when there is nothing to extend.** The hook checks `fastify.graphql?.schema?.getType("User")` first, so an app with `config.graphql.enabled = false` — or one that never merged `userSchema` — boots normally. Without that guard `extendSchema` throws `Cannot extend type "User" because it is not defined.` +- **Registration order does not matter.** The call happens on `onReady`, by which point mercurius has been registered regardless of whether this plugin was registered before or after `@prefabs.tech/fastify-graphql`. +- The REST response schema is separate and unaffected — `phoneNumber` is not serialized on the users REST routes. ## Configuration reference diff --git a/packages/passwordless/README.md b/packages/passwordless/README.md index f9e82ce0f..ecc1fe78a 100644 --- a/packages/passwordless/README.md +++ b/packages/passwordless/README.md @@ -10,7 +10,7 @@ SuperTokens ships a Passwordless recipe, but wiring it to Twilio Verify and to y - **Keep the auth package lean**: passwordless is opt-in. Apps that do not use it never install `twilio`, and `@prefabs.tech/fastify-user` carries no passwordless config surface. - **Initialise the recipe automatically**: registering this plugin is all it takes — the SuperTokens Passwordless recipe is contributed to `@prefabs.tech/fastify-user`'s recipe list for you. - **Create the local user row**: on first sign-in a matching row is created in your `users` table with the phone number and a synthetic `@` email, since SuperTokens requires an email. -- **Own the `phone_number` column**: an idempotent migration adds it to the users table, and the `User` type from `@prefabs.tech/fastify-user` is augmented with `phoneNumber?: string`. +- **Own the `phone_number` column**: an idempotent migration adds it to the users table, the `User` type from `@prefabs.tech/fastify-user` is augmented with `phoneNumber?: string`, and the GraphQL `User` type is extended at runtime — no wiring on your side. - **Support local development without Twilio**: a dev mode and a per-number bypass list accept a fixed OTP so you can develop and test without sending real SMS. ## Requirements diff --git a/packages/passwordless/src/__test__/extendUserSchema.test.ts b/packages/passwordless/src/__test__/extendUserSchema.test.ts new file mode 100644 index 000000000..7d01eb224 --- /dev/null +++ b/packages/passwordless/src/__test__/extendUserSchema.test.ts @@ -0,0 +1,77 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import plugin from "../plugin"; + +vi.mock("../migrations/runMigrations", () => ({ + default: vi.fn(), +})); + +// Stands in for the decorator mercurius adds; mercurius itself is not a +// dependency of this package. +const buildGraphqlDecorator = (userTypeExists: boolean) => ({ + extendSchema: vi.fn(), + schema: { getType: vi.fn(() => (userTypeExists ? {} : undefined)) }, +}); + +const buildFastify = ( + graphql?: ReturnType, +): FastifyInstance => { + const fastify = Fastify({ logger: false }); + + fastify.decorate("config", { appName: "Test App", passwordless: {} }); + fastify.decorate("slonik", {}); + + if (graphql) { + fastify.decorate( + "graphql", + graphql as unknown as FastifyInstance["graphql"], + ); + } + + return fastify; +}; + +describe("extendUserSchema", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("adds phoneNumber to the User type when the schema defines it", async () => { + const graphql = buildGraphqlDecorator(true); + fastify = buildFastify(graphql); + + await fastify.register(plugin); + await fastify.ready(); + + expect(graphql.schema.getType).toHaveBeenCalledWith("User"); + expect(graphql.extendSchema).toHaveBeenCalledTimes(1); + expect(graphql.extendSchema.mock.calls[0][0]).toContain("extend type User"); + expect(graphql.extendSchema.mock.calls[0][0]).toContain( + "phoneNumber: String", + ); + }); + + it("does not extend the schema when the User type is absent", async () => { + const graphql = buildGraphqlDecorator(false); + fastify = buildFastify(graphql); + + await fastify.register(plugin); + await fastify.ready(); + + expect(graphql.extendSchema).not.toHaveBeenCalled(); + }); + + it("does not extend the schema when mercurius is not registered", async () => { + fastify = buildFastify(); + + await fastify.register(plugin); + + await expect(fastify.ready()).resolves.toBeDefined(); + }); +}); diff --git a/packages/passwordless/src/graphql/extendUserSchema.ts b/packages/passwordless/src/graphql/extendUserSchema.ts new file mode 100644 index 000000000..88c2c5acc --- /dev/null +++ b/packages/passwordless/src/graphql/extendUserSchema.ts @@ -0,0 +1,21 @@ +import type { FastifyInstance } from "fastify"; + +const USER_SCHEMA_EXTENSION = ` + extend type User { + phoneNumber: String + } +`; + +const extendUserSchema = async (fastify: FastifyInstance) => { + // fastify.graphql exists only once mercurius is registered, and the extension + // needs the User type that @prefabs.tech/fastify-user contributes. Extending + // a type that is not defined throws, so an app running without GraphQL — or + // without the user schema merged — must be left alone. + if (!fastify.graphql?.schema?.getType("User")) { + return; + } + + await fastify.graphql.extendSchema(USER_SCHEMA_EXTENSION); +}; + +export default extendUserSchema; diff --git a/packages/passwordless/src/plugin.ts b/packages/passwordless/src/plugin.ts index 63b1724de..186cd876a 100644 --- a/packages/passwordless/src/plugin.ts +++ b/packages/passwordless/src/plugin.ts @@ -3,6 +3,7 @@ import type { FastifyPluginAsync } from "fastify"; import { addSupertokensRecipe } from "@prefabs.tech/fastify-user"; import FastifyPlugin from "fastify-plugin"; +import extendUserSchema from "./graphql/extendUserSchema"; import runMigrations from "./migrations/runMigrations"; import initPasswordlessRecipe from "./recipe/initPasswordlessRecipe"; @@ -23,6 +24,7 @@ const passwordlessPlugin: FastifyPluginAsync = async (fastify) => { // the table is guaranteed to exist. fastify.addHook("onReady", async () => { await runMigrations(fastify.config, fastify.slonik); + await extendUserSchema(fastify); }); }; From cfb6620f2c4a942c8f46748c74a83ba8229dcef6 Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 15:42:29 +0545 Subject: [PATCH 36/37] feat: rename package name to phone-auth --- README.md | 2 +- docs/specs/passwordless-package-extraction.md | 5 +++ .../{passwordless => phone-auth}/.gitignore | 0 .../{passwordless => phone-auth}/FEATURES.md | 18 +++++----- .../{passwordless => phone-auth}/GUIDE.md | 34 +++++++++--------- .../{passwordless => phone-auth}/README.md | 14 ++++---- .../eslint.config.js | 0 .../{passwordless => phone-auth}/package.json | 16 ++++----- .../src/__test__/extendUserSchema.test.ts | 2 +- .../src/__test__/plugin.test.ts | 10 +++--- .../src/__test__/recipeConfig.spec.ts | 8 ++--- .../src/constants.ts | 0 .../src/graphql/extendUserSchema.ts | 0 .../{passwordless => phone-auth}/src/index.ts | 4 +-- .../src/lib/getTwilioClient.ts | 6 ++-- .../src/migrations/queries.ts | 0 .../src/migrations/runMigrations.ts | 0 .../src/plugin.ts | 10 +++--- .../src/recipe/config.ts | 35 +++++++++---------- .../src/recipe/consumeCode.ts | 4 +-- .../src/recipe/consumeCodePost.ts | 14 ++++---- .../src/recipe/initPasswordlessRecipe.ts | 2 +- .../{passwordless => phone-auth}/src/types.ts | 4 +-- .../tsconfig.json | 0 .../vite.config.ts | 4 +-- packages/user/FEATURES.md | 2 +- packages/user/GUIDE.md | 2 +- pnpm-lock.yaml | 2 +- 28 files changed, 101 insertions(+), 97 deletions(-) rename packages/{passwordless => phone-auth}/.gitignore (100%) rename packages/{passwordless => phone-auth}/FEATURES.md (81%) rename packages/{passwordless => phone-auth}/GUIDE.md (92%) rename packages/{passwordless => phone-auth}/README.md (91%) rename packages/{passwordless => phone-auth}/eslint.config.js (100%) rename packages/{passwordless => phone-auth}/package.json (80%) rename packages/{passwordless => phone-auth}/src/__test__/extendUserSchema.test.ts (96%) rename packages/{passwordless => phone-auth}/src/__test__/plugin.test.ts (89%) rename packages/{passwordless => phone-auth}/src/__test__/recipeConfig.spec.ts (94%) rename packages/{passwordless => phone-auth}/src/constants.ts (100%) rename packages/{passwordless => phone-auth}/src/graphql/extendUserSchema.ts (100%) rename packages/{passwordless => phone-auth}/src/index.ts (87%) rename packages/{passwordless => phone-auth}/src/lib/getTwilioClient.ts (61%) rename packages/{passwordless => phone-auth}/src/migrations/queries.ts (100%) rename packages/{passwordless => phone-auth}/src/migrations/runMigrations.ts (100%) rename packages/{passwordless => phone-auth}/src/plugin.ts (73%) rename packages/{passwordless => phone-auth}/src/recipe/config.ts (82%) rename packages/{passwordless => phone-auth}/src/recipe/consumeCode.ts (96%) rename packages/{passwordless => phone-auth}/src/recipe/consumeCodePost.ts (87%) rename packages/{passwordless => phone-auth}/src/recipe/initPasswordlessRecipe.ts (88%) rename packages/{passwordless => phone-auth}/src/types.ts (97%) rename packages/{passwordless => phone-auth}/tsconfig.json (100%) rename packages/{passwordless => phone-auth}/vite.config.ts (94%) diff --git a/README.md b/README.md index 4ad0f62c3..fc4e50626 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A set of fastify libraries - @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) - @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) - @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) -- @prefabs.tech/fastify-passwordless (https://www.npmjs.com/package/@prefabs.tech/fastify-passwordless) +- @prefabs.tech/fastify-phone-auth (https://www.npmjs.com/package/@prefabs.tech/fastify-phone-auth) - @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) - @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) - @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) diff --git a/docs/specs/passwordless-package-extraction.md b/docs/specs/passwordless-package-extraction.md index 2c68c6cbf..9fc5e97d5 100644 --- a/docs/specs/passwordless-package-extraction.md +++ b/docs/specs/passwordless-package-extraction.md @@ -2,6 +2,11 @@ Status: implemented on branch `feat/passwordless-verify-service` (2026-07-27). +> Historical record. The package was later renamed to +> `@prefabs.tech/fastify-phone-auth` (`packages/phone-auth`) and its config +> namespace from `config.passwordless` to `config.phoneAuth`. Names below are +> as they were at the time of writing. + ## 1. Problem statement Passwordless login (phone/SMS OTP via Twilio Verify) was built inside diff --git a/packages/passwordless/.gitignore b/packages/phone-auth/.gitignore similarity index 100% rename from packages/passwordless/.gitignore rename to packages/phone-auth/.gitignore diff --git a/packages/passwordless/FEATURES.md b/packages/phone-auth/FEATURES.md similarity index 81% rename from packages/passwordless/FEATURES.md rename to packages/phone-auth/FEATURES.md index f99119c00..14b6238ba 100644 --- a/packages/passwordless/FEATURES.md +++ b/packages/phone-auth/FEATURES.md @@ -1,10 +1,10 @@ -# @prefabs.tech/fastify-passwordless — Features +# @prefabs.tech/fastify-phone-auth — Features ## Plugin Lifecycle -1. **Enable/disable via config flag** — when `config.passwordless.enabled === false`, no recipe factory is contributed and the SuperTokens passwordless endpoints are not served. The check is `=== false`; `undefined` means enabled. +1. **Enable/disable via config flag** — when `config.phoneAuth.enabled === false`, no recipe factory is contributed and the SuperTokens passwordless endpoints are not served. The check is `=== false`; `undefined` means enabled. 2. **Automatic recipe registration** — on registration (when enabled), the plugin pushes `initPasswordlessRecipe` into the SuperTokens recipe registry via `addSupertokensRecipe` from `@prefabs.tech/fastify-user`. No consumer wiring beyond registering the plugin is required. @@ -20,23 +20,23 @@ ## Recipe Configuration -8. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.passwordless`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. +8. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.phoneAuth`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported. -9. **Full recipe escape hatch** — when `config.passwordless.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. +9. **Full recipe escape hatch** — when `config.phoneAuth.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely. -10. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.passwordless` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. +10. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.phoneAuth` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot. -11. **API override wrappers** — each entry in `config.passwordless.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. +11. **API override wrappers** — each entry in `config.phoneAuth.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins. -12. **Function override wrappers** — same mechanism for `config.passwordless.override.functions` over the built-in `consumeCode` override. +12. **Function override wrappers** — same mechanism for `config.phoneAuth.override.functions` over the built-in `consumeCode` override. ## Twilio Verify Integration 13. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP. -14. **Dev mode OTP** — when `config.passwordless.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. +14. **Dev mode OTP** — when `config.phoneAuth.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number. -15. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.passwordless.bypassSmsFor` also get `devModeOtp` and no SMS is sent. +15. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.phoneAuth.bypassSmsFor` also get `devModeOtp` and no SMS is sent. 16. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown. diff --git a/packages/passwordless/GUIDE.md b/packages/phone-auth/GUIDE.md similarity index 92% rename from packages/passwordless/GUIDE.md rename to packages/phone-auth/GUIDE.md index 8d5ce9db6..f075163f9 100644 --- a/packages/passwordless/GUIDE.md +++ b/packages/phone-auth/GUIDE.md @@ -1,15 +1,15 @@ -# @prefabs.tech/fastify-passwordless — Developer Guide +# @prefabs.tech/fastify-phone-auth — Developer Guide ## Installation ### For package consumers ```bash -npm install @prefabs.tech/fastify-passwordless +npm install @prefabs.tech/fastify-phone-auth ``` ```bash -pnpm add @prefabs.tech/fastify-passwordless +pnpm add @prefabs.tech/fastify-phone-auth ``` Peer dependencies are listed in [README.md](./README.md#requirements). @@ -18,8 +18,8 @@ Peer dependencies are listed in [README.md](./README.md#requirements). ```bash pnpm install -pnpm --filter @prefabs.tech/fastify-passwordless test -pnpm --filter @prefabs.tech/fastify-passwordless build +pnpm --filter @prefabs.tech/fastify-phone-auth test +pnpm --filter @prefabs.tech/fastify-phone-auth build ``` ## Registration order — read this first @@ -29,7 +29,7 @@ SuperTokens permits exactly one global `supertokens.init()`. `@prefabs.tech/fast ```typescript await fastify.register(configPlugin, { config }); await fastify.register(slonikPlugin); -await fastify.register(passwordlessPlugin); // pushes the recipe factory +await fastify.register(phoneAuthPlugin); // pushes the recipe factory await fastify.register(userPlugin); // supertokens.init() drains the registry ``` @@ -48,14 +48,14 @@ The registry itself is `addSupertokensRecipe`, exported from `@prefabs.tech/fast import type { ApiConfig } from "@prefabs.tech/fastify-config"; import configPlugin from "@prefabs.tech/fastify-config"; -import passwordlessPlugin from "@prefabs.tech/fastify-passwordless"; +import phoneAuthPlugin from "@prefabs.tech/fastify-phone-auth"; import slonikPlugin from "@prefabs.tech/fastify-slonik"; import userPlugin from "@prefabs.tech/fastify-user"; import Fastify from "fastify"; const config: ApiConfig = { // ...the rest of your app config - passwordless: { + phoneAuth: { fallbackEmailDomain: "example.com", twilio: { accountSid: process.env.TWILIO_ACCOUNT_SID as string, @@ -69,7 +69,7 @@ const fastify = Fastify(); await fastify.register(configPlugin, { config }); await fastify.register(slonikPlugin); -await fastify.register(passwordlessPlugin); +await fastify.register(phoneAuthPlugin); await fastify.register(userPlugin); ``` @@ -152,7 +152,7 @@ Details: ## Configuration reference -`config.passwordless`: +`config.phoneAuth`: | Key | Type | Default | Notes | | --- | --- | --- | --- | @@ -172,7 +172,7 @@ Details: ### Disabling the plugin ```typescript -passwordless: { +phoneAuth: { enabled: false; } ``` @@ -182,7 +182,7 @@ No recipe is contributed and the SuperTokens passwordless endpoints are not serv ### Development without Twilio ```typescript -passwordless: { +phoneAuth: { devModeOtp: "123456", enableDevMode: true, fallbackEmailDomain: "example.com", @@ -196,7 +196,7 @@ Every number accepts `123456` and no SMS is sent. To keep Twilio live for real u Wrappers receive the original implementation and the Fastify instance, and are applied **after** the built-in overrides — so replacing `consumeCodePOST` or `consumeCode` removes the Twilio Verify integration or the local user creation respectively. ```typescript -passwordless: { +phoneAuth: { override: { apis: { consumeCodePOST: (originalImplementation, fastify) => async (input) => { @@ -212,7 +212,7 @@ passwordless: { For total control, bypass the generated config entirely: ```typescript -passwordless: { +phoneAuth: { recipe: (fastify) => ({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE", @@ -224,9 +224,9 @@ passwordless: { `getPasswordlessRecipeConfig` runs during `supertokens.init()`, so configuration mistakes fail at boot rather than on the first sign-in attempt: -- No `config.passwordless` at all → `Passwordless recipe config is missing.` -- `enableDevMode: true` without `devModeOtp` → `passwordless.devModeOtp is required when passwordless.enableDevMode is true` -- Not in dev mode and `twilio` missing or incomplete → `Twilio config is missing for the passwordless recipe.` / `accountSid and ... authToken are required` +- No `config.phoneAuth` at all → `Phone auth config is missing.` +- `enableDevMode: true` without `devModeOtp` → `phoneAuth.devModeOtp is required when phoneAuth.enableDevMode is true` +- Not in dev mode and `twilio` missing or incomplete → `Twilio config is missing for phone auth.` / `accountSid and ... authToken are required` At request time, a Twilio Verify failure is logged and returned as `RESTART_FLOW_ERROR`; a rejected code returns `INCORRECT_USER_INPUT_CODE_ERROR`. diff --git a/packages/passwordless/README.md b/packages/phone-auth/README.md similarity index 91% rename from packages/passwordless/README.md rename to packages/phone-auth/README.md index ecc1fe78a..afa72193f 100644 --- a/packages/passwordless/README.md +++ b/packages/phone-auth/README.md @@ -1,4 +1,4 @@ -# @prefabs.tech/fastify-passwordless +# @prefabs.tech/fastify-phone-auth A [Fastify](https://github.com/fastify/fastify) plugin that adds phone/SMS OTP passwordless login to an API built on [@prefabs.tech/fastify-user](../user/), backed by the [Twilio Verify](https://www.twilio.com/docs/verify) API. @@ -31,13 +31,13 @@ Peer dependencies (install compatible versions — see [package.json](./package. Install with npm: ```bash -npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user @prefabs.tech/fastify-passwordless fastify fastify-plugin slonik supertokens-node +npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user @prefabs.tech/fastify-phone-auth fastify fastify-plugin slonik supertokens-node ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user @prefabs.tech/fastify-passwordless fastify fastify-plugin slonik supertokens-node +pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user @prefabs.tech/fastify-phone-auth fastify fastify-plugin slonik supertokens-node ``` ## Usage @@ -48,7 +48,7 @@ SuperTokens allows exactly one global `init()`, and `@prefabs.tech/fastify-user` ```typescript import configPlugin from "@prefabs.tech/fastify-config"; -import passwordlessPlugin from "@prefabs.tech/fastify-passwordless"; +import phoneAuthPlugin from "@prefabs.tech/fastify-phone-auth"; import slonikPlugin from "@prefabs.tech/fastify-slonik"; import userPlugin from "@prefabs.tech/fastify-user"; import Fastify from "fastify"; @@ -57,7 +57,7 @@ const fastify = Fastify(); await fastify.register(configPlugin, { config }); await fastify.register(slonikPlugin); -await fastify.register(passwordlessPlugin); // contributes the recipe +await fastify.register(phoneAuthPlugin); // contributes the recipe await fastify.register(userPlugin); // runs supertokens.init() ``` @@ -72,7 +72,7 @@ SuperTokens is already initialised. Register SuperTokens recipe plugins before @ ```typescript const config: ApiConfig = { // ... - passwordless: { + phoneAuth: { fallbackEmailDomain: "example.com", twilio: { accountSid: process.env.TWILIO_ACCOUNT_SID, @@ -86,7 +86,7 @@ const config: ApiConfig = { For local development, skip Twilio entirely: ```typescript -passwordless: { +phoneAuth: { devModeOtp: "123456", enableDevMode: true, fallbackEmailDomain: "example.com", diff --git a/packages/passwordless/eslint.config.js b/packages/phone-auth/eslint.config.js similarity index 100% rename from packages/passwordless/eslint.config.js rename to packages/phone-auth/eslint.config.js diff --git a/packages/passwordless/package.json b/packages/phone-auth/package.json similarity index 80% rename from packages/passwordless/package.json rename to packages/phone-auth/package.json index 057e4c3cd..9fb6a487e 100644 --- a/packages/passwordless/package.json +++ b/packages/phone-auth/package.json @@ -1,23 +1,23 @@ { - "name": "@prefabs.tech/fastify-passwordless", + "name": "@prefabs.tech/fastify-phone-auth", "version": "0.94.1", - "description": "Fastify passwordless plugin", - "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/passwordless#readme", + "description": "Fastify phone auth plugin", + "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/phone-auth#readme", "repository": { "type": "git", "url": "git+https://github.com/prefabs-tech/fastify.git", - "directory": "packages/passwordless" + "directory": "packages/phone-auth" }, "license": "MIT", "type": "module", "exports": { ".": { - "import": "./dist/prefabs-tech-fastify-passwordless.js", - "require": "./dist/prefabs-tech-fastify-passwordless.cjs" + "import": "./dist/prefabs-tech-fastify-phone-auth.js", + "require": "./dist/prefabs-tech-fastify-phone-auth.cjs" } }, - "main": "./dist/prefabs-tech-fastify-passwordless.cjs", - "module": "./dist/prefabs-tech-fastify-passwordless.js", + "main": "./dist/prefabs-tech-fastify-phone-auth.cjs", + "module": "./dist/prefabs-tech-fastify-phone-auth.js", "types": "./dist/types/index.d.ts", "files": [ "dist" diff --git a/packages/passwordless/src/__test__/extendUserSchema.test.ts b/packages/phone-auth/src/__test__/extendUserSchema.test.ts similarity index 96% rename from packages/passwordless/src/__test__/extendUserSchema.test.ts rename to packages/phone-auth/src/__test__/extendUserSchema.test.ts index 7d01eb224..2fb41c43d 100644 --- a/packages/passwordless/src/__test__/extendUserSchema.test.ts +++ b/packages/phone-auth/src/__test__/extendUserSchema.test.ts @@ -22,7 +22,7 @@ const buildFastify = ( ): FastifyInstance => { const fastify = Fastify({ logger: false }); - fastify.decorate("config", { appName: "Test App", passwordless: {} }); + fastify.decorate("config", { appName: "Test App", phoneAuth: {} }); fastify.decorate("slonik", {}); if (graphql) { diff --git a/packages/passwordless/src/__test__/plugin.test.ts b/packages/phone-auth/src/__test__/plugin.test.ts similarity index 89% rename from packages/passwordless/src/__test__/plugin.test.ts rename to packages/phone-auth/src/__test__/plugin.test.ts index f4dfaa5c1..e43aac1f6 100644 --- a/packages/passwordless/src/__test__/plugin.test.ts +++ b/packages/phone-auth/src/__test__/plugin.test.ts @@ -12,18 +12,18 @@ vi.mock("../migrations/runMigrations", () => ({ })); /** - * Builds a Fastify instance decorated with everything the passwordless plugin + * Builds a Fastify instance decorated with everything the phone auth plugin * reads. `addSupertokensRecipe` comes from @prefabs.tech/fastify-user and only * touches decorators, so no SuperTokens init happens here. */ const buildFastify = ( - passwordlessConfig?: Record, + phoneAuthConfig?: Record, ): FastifyInstance => { const fastify = Fastify({ logger: false }); fastify.decorate("config", { appName: "Test App", - passwordless: passwordlessConfig, + phoneAuth: phoneAuthConfig, }); fastify.decorate("slonik", {}); @@ -31,7 +31,7 @@ const buildFastify = ( return fastify; }; -describe("passwordlessPlugin", () => { +describe("phoneAuthPlugin", () => { let fastify: FastifyInstance; beforeEach(() => { @@ -56,7 +56,7 @@ describe("passwordlessPlugin", () => { expect(fastify.supertokensRecipes).toHaveLength(1); }); - it("registers the recipe factory when the passwordless config is absent", async () => { + it("registers the recipe factory when the phone auth config is absent", async () => { fastify = buildFastify(); await fastify.register(plugin); diff --git a/packages/passwordless/src/__test__/recipeConfig.spec.ts b/packages/phone-auth/src/__test__/recipeConfig.spec.ts similarity index 94% rename from packages/passwordless/src/__test__/recipeConfig.spec.ts rename to packages/phone-auth/src/__test__/recipeConfig.spec.ts index 486801037..c191e14c8 100644 --- a/packages/passwordless/src/__test__/recipeConfig.spec.ts +++ b/packages/phone-auth/src/__test__/recipeConfig.spec.ts @@ -31,13 +31,13 @@ const twilio = { }; const buildFastify = ( - passwordlessConfig?: Record, + phoneAuthConfig?: Record, ): FastifyInstance => { const fastify = Fastify({ logger: false }); fastify.decorate("config", { appName: "Test App", - passwordless: passwordlessConfig, + phoneAuth: phoneAuthConfig, }); return fastify; @@ -67,11 +67,11 @@ describe("getPasswordlessRecipeConfig", () => { ); }); - it("throws when the passwordless config is missing", () => { + it("throws when the phone auth config is missing", () => { fastify = buildFastify(); expect(() => getPasswordlessRecipeConfig(fastify)).toThrow( - /Passwordless recipe config is missing/, + /Phone auth config is missing/, ); }); diff --git a/packages/passwordless/src/constants.ts b/packages/phone-auth/src/constants.ts similarity index 100% rename from packages/passwordless/src/constants.ts rename to packages/phone-auth/src/constants.ts diff --git a/packages/passwordless/src/graphql/extendUserSchema.ts b/packages/phone-auth/src/graphql/extendUserSchema.ts similarity index 100% rename from packages/passwordless/src/graphql/extendUserSchema.ts rename to packages/phone-auth/src/graphql/extendUserSchema.ts diff --git a/packages/passwordless/src/index.ts b/packages/phone-auth/src/index.ts similarity index 87% rename from packages/passwordless/src/index.ts rename to packages/phone-auth/src/index.ts index 78d3fb256..c82c3ce62 100644 --- a/packages/passwordless/src/index.ts +++ b/packages/phone-auth/src/index.ts @@ -1,8 +1,8 @@ -import type { PasswordlessConfig } from "./types"; +import type { PhoneAuthConfig } from "./types"; declare module "@prefabs.tech/fastify-config" { interface ApiConfig { - passwordless?: PasswordlessConfig; + phoneAuth?: PhoneAuthConfig; } } diff --git a/packages/passwordless/src/lib/getTwilioClient.ts b/packages/phone-auth/src/lib/getTwilioClient.ts similarity index 61% rename from packages/passwordless/src/lib/getTwilioClient.ts rename to packages/phone-auth/src/lib/getTwilioClient.ts index 4f130a326..ca3db3fc2 100644 --- a/packages/passwordless/src/lib/getTwilioClient.ts +++ b/packages/phone-auth/src/lib/getTwilioClient.ts @@ -5,19 +5,19 @@ import type { TwilioConfig } from "../types"; const getTwilioClient = (config: TwilioConfig | undefined) => { if (!config) { throw new Error( - "Twilio config is missing for the passwordless recipe. Add `passwordless.twilio` to your app config.", + "Twilio config is missing for phone auth. Add `phoneAuth.twilio` to your app config.", ); } if (!config.verifyServiceSid) { throw new Error( - "passwordless.twilio.verifyServiceSid is required for passwordless verification", + "phoneAuth.twilio.verifyServiceSid is required for phone auth verification", ); } if (!config.accountSid || !config.authToken) { throw new Error( - "passwordless.twilio.accountSid and passwordless.twilio.authToken are required for passwordless verification", + "phoneAuth.twilio.accountSid and phoneAuth.twilio.authToken are required for phone auth verification", ); } diff --git a/packages/passwordless/src/migrations/queries.ts b/packages/phone-auth/src/migrations/queries.ts similarity index 100% rename from packages/passwordless/src/migrations/queries.ts rename to packages/phone-auth/src/migrations/queries.ts diff --git a/packages/passwordless/src/migrations/runMigrations.ts b/packages/phone-auth/src/migrations/runMigrations.ts similarity index 100% rename from packages/passwordless/src/migrations/runMigrations.ts rename to packages/phone-auth/src/migrations/runMigrations.ts diff --git a/packages/passwordless/src/plugin.ts b/packages/phone-auth/src/plugin.ts similarity index 73% rename from packages/passwordless/src/plugin.ts rename to packages/phone-auth/src/plugin.ts index 186cd876a..8cd7bc943 100644 --- a/packages/passwordless/src/plugin.ts +++ b/packages/phone-auth/src/plugin.ts @@ -7,14 +7,14 @@ import extendUserSchema from "./graphql/extendUserSchema"; import runMigrations from "./migrations/runMigrations"; import initPasswordlessRecipe from "./recipe/initPasswordlessRecipe"; -const passwordlessPlugin: FastifyPluginAsync = async (fastify) => { - if (fastify.config.passwordless?.enabled === false) { - fastify.log.info("fastify-passwordless plugin is not enabled"); +const phoneAuthPlugin: FastifyPluginAsync = async (fastify) => { + if (fastify.config.phoneAuth?.enabled === false) { + fastify.log.info("fastify-phone-auth plugin is not enabled"); return; } - fastify.log.info("Registering fastify-passwordless plugin"); + fastify.log.info("Registering fastify-phone-auth plugin"); addSupertokensRecipe(fastify, initPasswordlessRecipe); @@ -28,4 +28,4 @@ const passwordlessPlugin: FastifyPluginAsync = async (fastify) => { }); }; -export default FastifyPlugin(passwordlessPlugin); +export default FastifyPlugin(phoneAuthPlugin); diff --git a/packages/passwordless/src/recipe/config.ts b/packages/phone-auth/src/recipe/config.ts similarity index 82% rename from packages/passwordless/src/recipe/config.ts rename to packages/phone-auth/src/recipe/config.ts index fad4bcee3..f231fd426 100644 --- a/packages/passwordless/src/recipe/config.ts +++ b/packages/phone-auth/src/recipe/config.ts @@ -5,7 +5,7 @@ import type { RecipeInterface, } from "supertokens-node/recipe/passwordless/types"; -import type { PasswordlessConfig } from "../types"; +import type { PhoneAuthConfig } from "../types"; import { DEFAULT_CONTACT_METHOD, @@ -34,36 +34,35 @@ import consumeCodePOST from "./consumeCodePost"; const getPasswordlessRecipeConfig = ( fastify: FastifyInstance, ): PasswordlessRecipeConfig => { - const passwordless: PasswordlessConfig | undefined = - fastify.config.passwordless; + const phoneAuth: PhoneAuthConfig | undefined = fastify.config.phoneAuth; - if (!passwordless) { + if (!phoneAuth) { throw new Error( - "Passwordless recipe config is missing. Add `passwordless` to your app config.", + "Phone auth config is missing. Add `phoneAuth` to your app config.", ); } - const isDevelopment = passwordless.enableDevMode === true; - const developmentModeOtp = passwordless.devModeOtp; + const isDevelopment = phoneAuth.enableDevMode === true; + const developmentModeOtp = phoneAuth.devModeOtp; if (isDevelopment && !developmentModeOtp) { throw new Error( - "passwordless.devModeOtp is required when passwordless.enableDevMode is true", + "phoneAuth.devModeOtp is required when phoneAuth.enableDevMode is true", ); } const isDevelopmentNumber = (phoneNumber: string) => { - return (passwordless.bypassSmsFor || []).includes(phoneNumber); + return (phoneAuth.bypassSmsFor || []).includes(phoneNumber); }; // Fail at boot rather than on the first sign-in attempt. if (!isDevelopment) { - getTwilioClient(passwordless.twilio); + getTwilioClient(phoneAuth.twilio); } return { - contactMethod: passwordless.contactMethod || DEFAULT_CONTACT_METHOD, - flowType: passwordless.flowType || DEFAULT_FLOW_TYPE, + contactMethod: phoneAuth.contactMethod || DEFAULT_CONTACT_METHOD, + flowType: phoneAuth.flowType || DEFAULT_FLOW_TYPE, getCustomUserInputCode: async (userContext) => { const phoneNumber = userContext?.phoneNumber as string | undefined; @@ -77,8 +76,8 @@ const getPasswordlessRecipeConfig = ( apis: (originalImplementation) => { const apiInterface: Partial = {}; - if (passwordless.override?.apis) { - const apis = passwordless.override.apis; + if (phoneAuth.override?.apis) { + const apis = phoneAuth.override.apis; let api: keyof APIInterface; @@ -111,8 +110,8 @@ const getPasswordlessRecipeConfig = ( functions: (originalImplementation) => { const recipeInterface: Partial = {}; - if (passwordless.override?.functions) { - const recipes = passwordless.override.functions; + if (phoneAuth.override?.functions) { + const recipes = phoneAuth.override.functions; let recipe: keyof RecipeInterface; @@ -140,7 +139,7 @@ const getPasswordlessRecipeConfig = ( ? { createAndSendCustomTextMessage: async () => { fastify.log.info( - `Skipping passwordless SMS delivery in development environment. Use default OTP [${developmentModeOtp}] for testing.`, + `Skipping phone auth SMS delivery in development environment. Use default OTP [${developmentModeOtp}] for testing.`, ); }, } @@ -159,7 +158,7 @@ const getPasswordlessRecipeConfig = ( } const { client, verifyServiceSid } = getTwilioClient( - passwordless.twilio, + phoneAuth.twilio, ); try { diff --git a/packages/passwordless/src/recipe/consumeCode.ts b/packages/phone-auth/src/recipe/consumeCode.ts similarity index 96% rename from packages/passwordless/src/recipe/consumeCode.ts rename to packages/phone-auth/src/recipe/consumeCode.ts index 74d0b68bb..e6fffdb4e 100644 --- a/packages/passwordless/src/recipe/consumeCode.ts +++ b/packages/phone-auth/src/recipe/consumeCode.ts @@ -48,7 +48,7 @@ const consumeCode = ( const phoneNumber = originalResponse.user.phoneNumber; const emailDomain = - fastify.config.passwordless?.fallbackEmailDomain || + fastify.config.phoneAuth?.fallbackEmailDomain || fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; const email = phoneNumber @@ -58,7 +58,7 @@ const consumeCode = ( if (!email || !phoneNumber) { await deleteUser(originalResponse.user.id); - throw new Error("Passwordless user missing phone number or email"); + throw new Error("Phone auth user missing phone number or email"); } let user: null | undefined | User; diff --git a/packages/passwordless/src/recipe/consumeCodePost.ts b/packages/phone-auth/src/recipe/consumeCodePost.ts similarity index 87% rename from packages/passwordless/src/recipe/consumeCodePost.ts rename to packages/phone-auth/src/recipe/consumeCodePost.ts index 9a57220fc..33d480702 100644 --- a/packages/passwordless/src/recipe/consumeCodePost.ts +++ b/packages/phone-auth/src/recipe/consumeCodePost.ts @@ -41,13 +41,13 @@ const consumeCodePOST = ( return originalImplementation.consumeCodePOST(input); } - const passwordless = fastify.config.passwordless; + const phoneAuth = fastify.config.phoneAuth; - if (!passwordless) { - throw new Error("Passwordless recipe config is missing"); + if (!phoneAuth) { + throw new Error("Phone auth config is missing"); } - const isDevelopment = passwordless.enableDevMode === true; + const isDevelopment = phoneAuth.enableDevMode === true; // Look up the device to retrieve the associated phone number const deviceContext = await Passwordless.listCodesByPreAuthSessionId({ @@ -59,8 +59,8 @@ const consumeCodePOST = ( } const { phoneNumber } = deviceContext; - const bypassNumbers = passwordless.bypassSmsFor ?? []; - const fallbackEmailDomain = passwordless.fallbackEmailDomain ?? ""; + const bypassNumbers = phoneAuth.bypassSmsFor ?? []; + const fallbackEmailDomain = phoneAuth.fallbackEmailDomain ?? ""; // In dev mode or for bypassed numbers, skip Twilio Verify and let // SuperTokens verify the code directly (uses devModeOtp) @@ -75,7 +75,7 @@ const consumeCodePOST = ( let client, verifyServiceSid; try { - ({ client, verifyServiceSid } = getTwilioClient(passwordless.twilio)); + ({ client, verifyServiceSid } = getTwilioClient(phoneAuth.twilio)); } catch (error) { fastify.log.error(error); diff --git a/packages/passwordless/src/recipe/initPasswordlessRecipe.ts b/packages/phone-auth/src/recipe/initPasswordlessRecipe.ts similarity index 88% rename from packages/passwordless/src/recipe/initPasswordlessRecipe.ts rename to packages/phone-auth/src/recipe/initPasswordlessRecipe.ts index e4f00a563..c9f8b9e8d 100644 --- a/packages/passwordless/src/recipe/initPasswordlessRecipe.ts +++ b/packages/phone-auth/src/recipe/initPasswordlessRecipe.ts @@ -5,7 +5,7 @@ import Passwordless from "supertokens-node/recipe/passwordless"; import getPasswordlessRecipeConfig from "./config"; const initPasswordlessRecipe = (fastify: FastifyInstance) => { - const recipe = fastify.config.passwordless?.recipe; + const recipe = fastify.config.phoneAuth?.recipe; if (typeof recipe === "function") { return Passwordless.init(recipe(fastify)); diff --git a/packages/passwordless/src/types.ts b/packages/phone-auth/src/types.ts similarity index 97% rename from packages/passwordless/src/types.ts rename to packages/phone-auth/src/types.ts index cb91f646d..47b8897d0 100644 --- a/packages/passwordless/src/types.ts +++ b/packages/phone-auth/src/types.ts @@ -13,7 +13,7 @@ type APIInterfaceWrapper = { ) => APIInterface[key]; }; -interface PasswordlessConfig { +interface PhoneAuthConfig { /** * Phone numbers that skip Twilio entirely and are verified against * `devModeOtp` instead. @@ -72,7 +72,7 @@ type TwilioConfig = Omit< export type { APIInterfaceWrapper, - PasswordlessConfig, + PhoneAuthConfig, RecipeInterfaceWrapper, TwilioConfig, }; diff --git a/packages/passwordless/tsconfig.json b/packages/phone-auth/tsconfig.json similarity index 100% rename from packages/passwordless/tsconfig.json rename to packages/phone-auth/tsconfig.json diff --git a/packages/passwordless/vite.config.ts b/packages/phone-auth/vite.config.ts similarity index 94% rename from packages/passwordless/vite.config.ts rename to packages/phone-auth/vite.config.ts index 3f6ab0528..9a4a5876d 100644 --- a/packages/passwordless/vite.config.ts +++ b/packages/phone-auth/vite.config.ts @@ -15,9 +15,9 @@ export default defineConfig(({ mode }) => { path.dirname(fileURLToPath(import.meta.url)), "src/index.ts", ), - fileName: "prefabs-tech-fastify-passwordless", + fileName: "prefabs-tech-fastify-phone-auth", formats: ["cjs", "es"], - name: "PrefabsTechFastifyPasswordless", + name: "PrefabsTechFastifyPhoneAuth", }, rolldownOptions: { external: [ diff --git a/packages/user/FEATURES.md b/packages/user/FEATURES.md index ff29302dc..0efbe1aa0 100644 --- a/packages/user/FEATURES.md +++ b/packages/user/FEATURES.md @@ -12,7 +12,7 @@ 4. **Default role seeding** — on `onReady`, seeds `ADMIN`, `SUPERADMIN`, and `USER` into SuperTokens, plus any extra roles listed in `config.user.roles`. -5. **SuperTokens recipe registry** — `addSupertokensRecipe(fastify, factory)` lets another plugin contribute a SuperTokens recipe. Factories are collected on the `fastify.supertokensRecipes` decorator and drained by `getRecipeList` during `supertokens.init()`. Since SuperTokens allows exactly one global `init()` and this package performs it during its own registration, contributing plugins must be registered **before** it; `addSupertokensRecipe` throws once `fastify.supertokensInitialized` is set. Used by `@prefabs.tech/fastify-passwordless`. +5. **SuperTokens recipe registry** — `addSupertokensRecipe(fastify, factory)` lets another plugin contribute a SuperTokens recipe. Factories are collected on the `fastify.supertokensRecipes` decorator and drained by `getRecipeList` during `supertokens.init()`. Since SuperTokens allows exactly one global `init()` and this package performs it during its own registration, contributing plugins must be registered **before** it; `addSupertokensRecipe` throws once `fastify.supertokensInitialized` is set. Used by `@prefabs.tech/fastify-phone-auth`. ## Authentication diff --git a/packages/user/GUIDE.md b/packages/user/GUIDE.md index bfe3048f9..ab6bf2c41 100644 --- a/packages/user/GUIDE.md +++ b/packages/user/GUIDE.md @@ -275,7 +275,7 @@ await fastify.register(myRecipePlugin); await fastify.register(userPlugin); ``` -Registering it afterwards throws `SuperTokens is already initialised. Register SuperTokens recipe plugins before @prefabs.tech/fastify-user.` rather than silently dropping the recipe. `@prefabs.tech/fastify-passwordless` is built on this hook. +Registering it afterwards throws `SuperTokens is already initialised. Register SuperTokens recipe plugins before @prefabs.tech/fastify-user.` rather than silently dropping the recipe. `@prefabs.tech/fastify-phone-auth` is built on this hook. ### Third-party OAuth providers diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4367f5df..c7b6fb141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,7 +332,7 @@ importers: specifier: 3.2.7 version: 3.2.7(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.1) - packages/passwordless: + packages/phone-auth: dependencies: twilio: specifier: 6.0.0 From facf461c54e74fbe836ed5fa99e8ca1a790463c8 Mon Sep 17 00:00:00 2001 From: anvesh Date: Mon, 3 Aug 2026 17:44:15 +0545 Subject: [PATCH 37/37] chore: remove extraction plan docs --- docs/specs/passwordless-package-extraction.md | 145 ------------------ 1 file changed, 145 deletions(-) delete mode 100644 docs/specs/passwordless-package-extraction.md diff --git a/docs/specs/passwordless-package-extraction.md b/docs/specs/passwordless-package-extraction.md deleted file mode 100644 index 9fc5e97d5..000000000 --- a/docs/specs/passwordless-package-extraction.md +++ /dev/null @@ -1,145 +0,0 @@ -# Spec: Extract passwordless login into `@prefabs.tech/fastify-passwordless` - -Status: implemented on branch `feat/passwordless-verify-service` (2026-07-27). - -> Historical record. The package was later renamed to -> `@prefabs.tech/fastify-phone-auth` (`packages/phone-auth`) and its config -> namespace from `config.passwordless` to `config.phoneAuth`. Names below are -> as they were at the time of writing. - -## 1. Problem statement - -Passwordless login (phone/SMS OTP via Twilio Verify) was built inside -`packages/user` with three disjoint config surfaces — -`user.features.passwordlessLogin.enabled`, `user.passwordLessConfig`, and -`user.supertokens.recipes.passwordless` — and pulled `twilio` into the runtime -`dependencies` of the auth package that every consumer installs. It is an -opt-in feature that a minority of apps use. - -## 2. The constraint that shapes the design - -**SuperTokens permits exactly one global `supertokens.init()`.** -`packages/user/src/supertokens/init.ts` calls it synchronously during plugin -registration, with `recipeList: getRecipeList(fastify)` fixed at that moment. A -plugin registered *after* `fastify-user` therefore cannot contribute a recipe — -there is no post-init recipe API. - -Two mechanisms were considered: - -1. **Registry + register-before-user (chosen).** `fastify-user` keeps `init()` - where it is. Recipe packages push a factory into a `fastify.supertokensRecipes` - decorator that `getRecipeList` drains. Wrong order throws. -2. **Registry + defer `init()` to `onReady`.** Order-independent. Rejected: it - changes init timing for every already-published `fastify-user` consumer, and - any consumer calling a SuperTokens API between `register` and `ready` would - break. - -Worth recording for whoever revisits option 2: it *is* technically viable. -`supertokens-node@14.1.4`'s Fastify plugin resolves the singleton only inside a -`preHandler` (`lib/build/framework/fastify/framework.js:199-212`), not at -registration time. `seedRoles` is already an `onReady` hook added after -`register(supertokensPlugin)`, so an init-in-`onReady` added earlier would still -sequence correctly. The blocker is consumer compatibility, not the SDK. - -## 3. Target design - -```typescript -// packages/user — new public API -addSupertokensRecipe(fastify, (fastify) => RecipeListFunction): void -``` - -- Throws when `fastify.hasDecorator("supertokensInitialized")` — i.e. when - called after `fastify-user` registered — with a message naming the fix. -- Lazily creates the `supertokensRecipes` decorator, so no ordering requirement - between multiple recipe packages. -- `init.ts` sets `supertokensInitialized` after `supertokens.init(...)`. -- Both plugins are `fastify-plugin`-wrapped, so decorators land on the same root - instance and encapsulation never enters the picture. - -Consumer order: - -```typescript -await fastify.register(passwordlessPlugin); // pushes the recipe factory -await fastify.register(userPlugin); // init() drains the registry -``` - -The new package collapses the three config surfaces into one -`config.passwordless` namespace and owns `twilio`. - -## 4. Why this was safe to do non-additively - -All passwordless code was branch-local. Verified before designing: - -```bash -git show main:packages/user/src/types/config.ts | grep -i twilio # no match -git show main:packages/user/src/supertokens/types/index.ts | grep -i passwordless -git grep -il passwordless main -- packages/ # empty -``` - -Nothing was published, so removing `passwordLessConfig`, -`features.passwordlessLogin`, `TwilioConfig` and `SupertokensRecipes.passwordless` -from `UserConfig` is additive from an npm consumer's point of view and did not -trip CLAUDE.md escalation item 1. **Run this check before any "clean removal" -claim** — it is the difference between a refactor and a breaking change. - -## 5. Bug found and fixed in passing - -Passwordless signup was broken at runtime. `consumeCode` inserted `phoneNumber`, -`DefaultSqlFactory` decamelized it to a `phone_number` column that did not -exist, and an `as UserCreateInput` cast was what let it compile. Fixed in -`packages/user` (it owns the users table): - -- `phoneNumber?: string` on `User`, omitted from `UserUpdateInput`; -- `phoneNumber` added to the **runtime** denylist in `filterUserUpdateInput` — - the `Omit` in the type is not enforcement, and every other immutable field is - on that list; -- additive idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number`, - mirroring the existing `addProfileInUsersTableQuery`; -- the cast deleted. - -**Rule extracted:** an `as SomeInput` cast on a `BaseService.create` argument is -a smell, not a convenience — `getCreateSql` decamelizes *every* key into a -column name, so the cast converts a compile error into a runtime -undefined-column error. - -`UserSqlFactory` inherits `_validationSchema = z.any()` from `DefaultSqlFactory`, -so no zod schema needed widening. Check this before assuming a new column needs -a schema change. - -## 6. Gotchas paid for during implementation - -1. **Vite `external` does not match subpaths.** `Object.keys(peerDependencies)` - externalizes the bare specifier only, so `supertokens-node/recipe/passwordless` - was bundled: the first passwordless build was **1.1 MB** and transformed 302 - modules. `packages/user/vite.config.ts` already carried the fix — - `/supertokens-node+/` in the `external` array. Adding it dropped the bundle to - 6 kB / 9 modules. Any new package importing `supertokens-node` subpaths needs - that regex. A suspiciously large `dist/` is the symptom. - -2. **`expect(mockFn).toHaveBeenCalledWith(fastifyInstance)` throws.** Vitest - deep-equals the argument, which touches Fastify getters that fail before the - server is listening (`TypeError: Cannot read properties of undefined (reading - 'family')`, `fastify.js:296`). Use an identity check on - `mockFn.mock.calls[0][0]` instead. - -3. **`supertokens.init()` is a process-global singleton, so tests must not go - through `register(userPlugin)` twice.** The second registration throws - "already initialised", which makes an ordering test pass for the wrong - reason. Test `addSupertokensRecipe` and `getRecipeList` directly against a - real-but-unregistered Fastify instance decorated with `config`, and stub the - individual recipe inits. - -4. **`pnpm -r install` aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`** - in a non-interactive shell when the workspace layout changed. `CI=true` plus - `--no-frozen-lockfile` is the fix for the run that introduces a new package. - -5. **`unicorn/no-unreadable-for-of-expression`** rejects - `for (const x of a ?? [])`. Hoist the fallback into a `const` first. - -## 7. Not verified - -An end-to-end dev-mode signup against a live SuperTokens core was not run — -there is no consumer app in this repo. What *was* verified for the -`phone_number` fix: the rendered migration SQL (default and overridden table -name), its idempotency under `pg-mem` across two applications, and the removal -of the cast under `tsc`.