@prefabs.tech/fastify-phone-auth 0.0.0-stage → 0.95.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # @prefabs.tech/fastify-phone-auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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.
4
+
5
+ ## Why this plugin?
6
+
7
+ 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:
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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 `<phoneNumber>@<fallbackEmailDomain>` email, since SuperTokens requires an email.
13
+ - **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.
14
+ - **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.
15
+
16
+ ## Requirements
17
+
18
+ Peer dependencies (install compatible versions — see [package.json](./package.json)):
19
+
20
+ - [@prefabs.tech/fastify-config](../config/)
21
+ - [@prefabs.tech/fastify-error-handler](../error-handler/)
22
+ - [@prefabs.tech/fastify-slonik](../slonik/)
23
+ - [@prefabs.tech/fastify-user](../user/)
24
+ - [`fastify`](https://www.npmjs.com/package/fastify)
25
+ - [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin)
26
+ - [`slonik`](https://www.npmjs.com/package/slonik)
27
+ - [`supertokens-node`](https://www.npmjs.com/package/supertokens-node)
28
+
29
+ ## Installation
30
+
31
+ Install with npm:
32
+
33
+ ```bash
34
+ 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
35
+ ```
36
+
37
+ Install with pnpm:
38
+
39
+ ```bash
40
+ 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
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### Register the plugin — before `@prefabs.tech/fastify-user`
46
+
47
+ 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.
48
+
49
+ ```typescript
50
+ import configPlugin from "@prefabs.tech/fastify-config";
51
+ import phoneAuthPlugin from "@prefabs.tech/fastify-phone-auth";
52
+ import slonikPlugin from "@prefabs.tech/fastify-slonik";
53
+ import userPlugin from "@prefabs.tech/fastify-user";
54
+ import Fastify from "fastify";
55
+
56
+ const fastify = Fastify();
57
+
58
+ await fastify.register(configPlugin, { config });
59
+ await fastify.register(slonikPlugin);
60
+ await fastify.register(phoneAuthPlugin); // contributes the recipe
61
+ await fastify.register(userPlugin); // runs supertokens.init()
62
+ ```
63
+
64
+ Registering it after `@prefabs.tech/fastify-user` throws:
65
+
66
+ ```
67
+ SuperTokens is already initialised. Register SuperTokens recipe plugins before @prefabs.tech/fastify-user.
68
+ ```
69
+
70
+ ### Configuration
71
+
72
+ ```typescript
73
+ const config: ApiConfig = {
74
+ // ...
75
+ phoneAuth: {
76
+ fallbackEmailDomain: "example.com",
77
+ twilio: {
78
+ accountSid: process.env.TWILIO_ACCOUNT_SID,
79
+ authToken: process.env.TWILIO_AUTH_TOKEN,
80
+ verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID,
81
+ },
82
+ },
83
+ };
84
+ ```
85
+
86
+ For local development, skip Twilio entirely:
87
+
88
+ ```typescript
89
+ phoneAuth: {
90
+ devModeOtp: "123456",
91
+ enableDevMode: true,
92
+ fallbackEmailDomain: "example.com",
93
+ }
94
+ ```
95
+
96
+ See the [developer guide](./GUIDE.md) for the full configuration reference, the SuperTokens endpoints this exposes, and the override hooks.
@@ -0,0 +1,8 @@
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require("twilio");c=s(c,1);let l=require("@prefabs.tech/fastify-user"),u=require("fastify-plugin");u=s(u,1);let d=require("slonik"),f=require("supertokens-node/recipe/passwordless");f=s(f,1);let p=require("@prefabs.tech/fastify-error-handler"),m=require("@prefabs.tech/fastify-slonik"),h=require("supertokens-node"),g=require("supertokens-node/recipe/userroles");g=s(g,1);var _=`PHONE`,v=`USER_INPUT_CODE`,y={SIGNUP_FAILED_ERROR:`SIGNUP_FAILED_ERROR`},b=`000000`,x=e=>{if(!e)throw Error("Twilio config is missing for phone auth. Add `phoneAuth.twilio` to your app config.");if(!e.verifyServiceSid)throw Error(`phoneAuth.twilio.verifyServiceSid is required for phone auth verification`);if(!e.accountSid||!e.authToken)throw Error(`phoneAuth.twilio.accountSid and phoneAuth.twilio.authToken are required for phone auth verification`);return{client:(0,c.default)(e.accountSid,e.authToken),verifyServiceSid:e.verifyServiceSid}},S=`
2
+ extend type User {
3
+ phoneNumber: String
4
+ }
5
+ `,C=async e=>{e.graphql?.schema?.getType(`User`)&&await e.graphql.extendSchema(S)},w=e=>{let t=e.user.tables?.users?.name||l.TABLE_USERS;return d.sql.unsafe`
6
+ ALTER TABLE ${d.sql.identifier([t])}
7
+ ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 );
8
+ `},T=async(e,t)=>{await t.connect(async t=>{await t.query(w(e))})},E=(e,t)=>async n=>{let r=n.userContext.roles||[t.config.user.role||l.ROLE_USER];if(!await(0,l.areRolesExist)(r))throw new p.CustomError(`At least one role from ${r.join(`, `)} does not exist.`,y.SIGNUP_FAILED_ERROR);let i=await e.consumeCode(n);if(i.status!==`OK`)return i;let a=(0,h.getRequestFromUserContext)(n.userContext)?.original,o=(0,l.getUserService)(a?.config||t.config,a?.slonik||t.slonik,a?.dbSchema),s=i.user.phoneNumber,c=t.config.phoneAuth?.fallbackEmailDomain||t.config.appName.toLowerCase().replaceAll(/\s+/g,``)+`.com`,u=s?`${s}@${c}`:i.user.email;if(!u||!s)throw await(0,h.deleteUser)(i.user.id),Error(`Phone auth user missing phone number or email`);let d;if(i.createdNewUser){try{if(d=await o.create({email:u,id:i.user.id,phoneNumber:s}),!d)throw Error(`User not found`)}catch(e){throw await(0,h.deleteUser)(i.user.id),e}d.roles=r,i.user={...i.user,...d};for(let e of r){let n=await g.default.addRoleToUser(i.user.id,e);n.status!==`OK`&&t.log.error(n.status)}}else await o.update(i.user.id,{lastLoginAt:(0,m.formatDate)(new Date(Date.now()))}).catch(e=>{t.log.error(`Unable to update lastLoginAt for userId ${i.user.id}`),t.log.error(e)});return{...i,user:{...i.user,email:u,phoneNumber:s}}},D=(e,t,n)=>e.status===`OK`?{...e,user:{...e.user,email:e.user.email??`${t}@${n}`}}:e,O=(e,t)=>async n=>{if(n.userContext.roles||=[t.config.user.role||l.ROLE_USER],e.consumeCodePOST===void 0)throw Error(`Should never come here`);if(!(`userInputCode`in n)||n.userInputCode===void 0)return e.consumeCodePOST(n);let r=t.config.phoneAuth;if(!r)throw Error(`Phone auth config is missing`);let i=r.enableDevMode===!0,a=await f.default.listCodesByPreAuthSessionId({preAuthSessionId:n.preAuthSessionId});if(!a||!a.phoneNumber)return{status:`RESTART_FLOW_ERROR`};let{phoneNumber:o}=a,s=r.bypassSmsFor??[],c=r.fallbackEmailDomain??``;if(i||s.includes(o))return D(await e.consumeCodePOST(n),o,c);let u,d;try{({client:u,verifyServiceSid:d}=x(r.twilio))}catch(e){return t.log.error(e),{status:`RESTART_FLOW_ERROR`}}try{return(await u.verify.v2.services(d).verificationChecks.create({code:n.userInputCode,to:o})).status===`approved`?D(await e.consumeCodePOST({...n,userInputCode:b}),o,c):{failedCodeInputAttemptCount:1,maximumCodeInputAttempts:5,status:`INCORRECT_USER_INPUT_CODE_ERROR`}}catch(e){return t.log.error(e,`Twilio Verify verification check failed`),{status:`RESTART_FLOW_ERROR`}}},k=e=>{let t=e.config.phoneAuth;if(!t)throw Error("Phone auth config is missing. Add `phoneAuth` to your app config.");let n=t.enableDevMode===!0,r=t.devModeOtp;if(n&&!r)throw Error(`phoneAuth.devModeOtp is required when phoneAuth.enableDevMode is true`);let i=e=>(t.bypassSmsFor||[]).includes(e);return n||x(t.twilio),{contactMethod:t.contactMethod||`PHONE`,flowType:t.flowType||`USER_INPUT_CODE`,getCustomUserInputCode:async e=>{let t=e?.phoneNumber;return n||t&&i(t)?r:b},override:{apis:n=>{let r={};if(t.override?.apis){let i=t.override.apis,a;for(a in i){let t=i[a];t&&(r[a]=t(n,e))}}return{...n,consumeCodePOST:O(n,e),createCodePOST:async e=>(`phoneNumber`in e&&(e.userContext.phoneNumber=e.phoneNumber),n.createCodePOST(e)),...r}},functions:n=>{let r={};if(t.override?.functions){let i=t.override.functions,a;for(a in i){let t=i[a];t&&(r[a]=t(n,e))}}return{...n,consumeCode:E(n,e),...r}}},...n?{createAndSendCustomTextMessage:async()=>{e.log.info(`Skipping phone auth SMS delivery in development environment. Use default OTP [${r}] for testing.`)}}:{smsDelivery:{override:n=>({...n,sendSms:async n=>{if(i(n.phoneNumber)){e.log.info(`Skipping SMS for test number ${n.phoneNumber}.`);return}let{client:r,verifyServiceSid:a}=x(t.twilio);try{await r.verify.v2.services(a).verifications.create({channel:`sms`,to:n.phoneNumber})}catch(t){throw e.log.error(t,`Twilio Verify failed to send OTP`),t}}})}}}},A=e=>{let t=e.config.phoneAuth?.recipe;return typeof t==`function`?f.default.init(t(e)):f.default.init(k(e))},j=(0,u.default)(async e=>{if(e.config.phoneAuth?.enabled===!1){e.log.info(`fastify-phone-auth plugin is not enabled`);return}e.log.info(`Registering fastify-phone-auth plugin`),(0,l.addSupertokensRecipe)(e,A),e.addHook(`onReady`,async()=>{await T(e.config,e.slonik),await C(e)})});exports.DEFAULT_CONTACT_METHOD=_,exports.DEFAULT_FLOW_TYPE=v,exports.ERROR_CODES=y,exports.TWILIO_VERIFY_PLACEHOLDER_CODE=b,exports.consumeCode=E,exports.consumeCodePOST=O,exports.default=j,exports.getPasswordlessRecipeConfig=k,exports.getTwilioClient=x,exports.initPasswordlessRecipe=A;
@@ -0,0 +1,184 @@
1
+ import e from "twilio";
2
+ import { ROLE_USER as t, TABLE_USERS as n, addSupertokensRecipe as r, areRolesExist as i, getUserService as a } from "@prefabs.tech/fastify-user";
3
+ import o from "fastify-plugin";
4
+ import { sql as s } from "slonik";
5
+ import c from "supertokens-node/recipe/passwordless";
6
+ import { CustomError as l } from "@prefabs.tech/fastify-error-handler";
7
+ import { formatDate as u } from "@prefabs.tech/fastify-slonik";
8
+ import { deleteUser as d, getRequestFromUserContext as f } from "supertokens-node";
9
+ import p from "supertokens-node/recipe/userroles";
10
+ //#region src/constants.ts
11
+ var m = "PHONE", h = "USER_INPUT_CODE", g = { SIGNUP_FAILED_ERROR: "SIGNUP_FAILED_ERROR" }, _ = "000000", v = (t) => {
12
+ if (!t) throw Error("Twilio config is missing for phone auth. Add `phoneAuth.twilio` to your app config.");
13
+ if (!t.verifyServiceSid) throw Error("phoneAuth.twilio.verifyServiceSid is required for phone auth verification");
14
+ if (!t.accountSid || !t.authToken) throw Error("phoneAuth.twilio.accountSid and phoneAuth.twilio.authToken are required for phone auth verification");
15
+ return {
16
+ client: e(t.accountSid, t.authToken),
17
+ verifyServiceSid: t.verifyServiceSid
18
+ };
19
+ }, y = "\n extend type User {\n phoneNumber: String\n }\n", b = async (e) => {
20
+ e.graphql?.schema?.getType("User") && await e.graphql.extendSchema(y);
21
+ }, x = (e) => {
22
+ let t = e.user.tables?.users?.name || n;
23
+ return s.unsafe`
24
+ ALTER TABLE ${s.identifier([t])}
25
+ ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 );
26
+ `;
27
+ }, S = async (e, t) => {
28
+ await t.connect(async (t) => {
29
+ await t.query(x(e));
30
+ });
31
+ }, C = (e, n) => async (r) => {
32
+ let o = r.userContext.roles || [n.config.user.role || t];
33
+ if (!await i(o)) throw new l(`At least one role from ${o.join(", ")} does not exist.`, g.SIGNUP_FAILED_ERROR);
34
+ let s = await e.consumeCode(r);
35
+ if (s.status !== "OK") return s;
36
+ let c = f(r.userContext)?.original, m = a(c?.config || n.config, c?.slonik || n.slonik, c?.dbSchema), h = s.user.phoneNumber, _ = n.config.phoneAuth?.fallbackEmailDomain || n.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com", v = h ? `${h}@${_}` : s.user.email;
37
+ if (!v || !h) throw await d(s.user.id), Error("Phone auth user missing phone number or email");
38
+ let y;
39
+ if (s.createdNewUser) {
40
+ try {
41
+ if (y = await m.create({
42
+ email: v,
43
+ id: s.user.id,
44
+ phoneNumber: h
45
+ }), !y) throw Error("User not found");
46
+ } catch (e) {
47
+ throw await d(s.user.id), e;
48
+ }
49
+ y.roles = o, s.user = {
50
+ ...s.user,
51
+ ...y
52
+ };
53
+ for (let e of o) {
54
+ let t = await p.addRoleToUser(s.user.id, e);
55
+ t.status !== "OK" && n.log.error(t.status);
56
+ }
57
+ } else await m.update(s.user.id, { lastLoginAt: u(new Date(Date.now())) }).catch((e) => {
58
+ n.log.error(`Unable to update lastLoginAt for userId ${s.user.id}`), n.log.error(e);
59
+ });
60
+ return {
61
+ ...s,
62
+ user: {
63
+ ...s.user,
64
+ email: v,
65
+ phoneNumber: h
66
+ }
67
+ };
68
+ }, w = (e, t, n) => e.status === "OK" ? {
69
+ ...e,
70
+ user: {
71
+ ...e.user,
72
+ email: e.user.email ?? `${t}@${n}`
73
+ }
74
+ } : e, T = (e, n) => async (r) => {
75
+ if (r.userContext.roles ||= [n.config.user.role || t], e.consumeCodePOST === void 0) throw Error("Should never come here");
76
+ if (!("userInputCode" in r) || r.userInputCode === void 0) return e.consumeCodePOST(r);
77
+ let i = n.config.phoneAuth;
78
+ if (!i) throw Error("Phone auth config is missing");
79
+ let a = i.enableDevMode === !0, o = await c.listCodesByPreAuthSessionId({ preAuthSessionId: r.preAuthSessionId });
80
+ if (!o || !o.phoneNumber) return { status: "RESTART_FLOW_ERROR" };
81
+ let { phoneNumber: s } = o, l = i.bypassSmsFor ?? [], u = i.fallbackEmailDomain ?? "";
82
+ if (a || l.includes(s)) return w(await e.consumeCodePOST(r), s, u);
83
+ let d, f;
84
+ try {
85
+ ({client: d, verifyServiceSid: f} = v(i.twilio));
86
+ } catch (e) {
87
+ return n.log.error(e), { status: "RESTART_FLOW_ERROR" };
88
+ }
89
+ try {
90
+ return (await d.verify.v2.services(f).verificationChecks.create({
91
+ code: r.userInputCode,
92
+ to: s
93
+ })).status === "approved" ? w(await e.consumeCodePOST({
94
+ ...r,
95
+ userInputCode: _
96
+ }), s, u) : {
97
+ failedCodeInputAttemptCount: 1,
98
+ maximumCodeInputAttempts: 5,
99
+ status: "INCORRECT_USER_INPUT_CODE_ERROR"
100
+ };
101
+ } catch (e) {
102
+ return n.log.error(e, "Twilio Verify verification check failed"), { status: "RESTART_FLOW_ERROR" };
103
+ }
104
+ }, E = (e) => {
105
+ let t = e.config.phoneAuth;
106
+ if (!t) throw Error("Phone auth config is missing. Add `phoneAuth` to your app config.");
107
+ let n = t.enableDevMode === !0, r = t.devModeOtp;
108
+ if (n && !r) throw Error("phoneAuth.devModeOtp is required when phoneAuth.enableDevMode is true");
109
+ let i = (e) => (t.bypassSmsFor || []).includes(e);
110
+ return n || v(t.twilio), {
111
+ contactMethod: t.contactMethod || "PHONE",
112
+ flowType: t.flowType || "USER_INPUT_CODE",
113
+ getCustomUserInputCode: async (e) => {
114
+ let t = e?.phoneNumber;
115
+ return n || t && i(t) ? r : _;
116
+ },
117
+ override: {
118
+ apis: (n) => {
119
+ let r = {};
120
+ if (t.override?.apis) {
121
+ let i = t.override.apis, a;
122
+ for (a in i) {
123
+ let t = i[a];
124
+ t && (r[a] = t(n, e));
125
+ }
126
+ }
127
+ return {
128
+ ...n,
129
+ consumeCodePOST: T(n, e),
130
+ createCodePOST: async (e) => ("phoneNumber" in e && (e.userContext.phoneNumber = e.phoneNumber), n.createCodePOST(e)),
131
+ ...r
132
+ };
133
+ },
134
+ functions: (n) => {
135
+ let r = {};
136
+ if (t.override?.functions) {
137
+ let i = t.override.functions, a;
138
+ for (a in i) {
139
+ let t = i[a];
140
+ t && (r[a] = t(n, e));
141
+ }
142
+ }
143
+ return {
144
+ ...n,
145
+ consumeCode: C(n, e),
146
+ ...r
147
+ };
148
+ }
149
+ },
150
+ ...n ? { createAndSendCustomTextMessage: async () => {
151
+ e.log.info(`Skipping phone auth SMS delivery in development environment. Use default OTP [${r}] for testing.`);
152
+ } } : { smsDelivery: { override: (n) => ({
153
+ ...n,
154
+ sendSms: async (n) => {
155
+ if (i(n.phoneNumber)) {
156
+ e.log.info(`Skipping SMS for test number ${n.phoneNumber}.`);
157
+ return;
158
+ }
159
+ let { client: r, verifyServiceSid: a } = v(t.twilio);
160
+ try {
161
+ await r.verify.v2.services(a).verifications.create({
162
+ channel: "sms",
163
+ to: n.phoneNumber
164
+ });
165
+ } catch (t) {
166
+ throw e.log.error(t, "Twilio Verify failed to send OTP"), t;
167
+ }
168
+ }
169
+ }) } }
170
+ };
171
+ }, D = (e) => {
172
+ let t = e.config.phoneAuth?.recipe;
173
+ return typeof t == "function" ? c.init(t(e)) : c.init(E(e));
174
+ }, O = o(async (e) => {
175
+ if (e.config.phoneAuth?.enabled === !1) {
176
+ e.log.info("fastify-phone-auth plugin is not enabled");
177
+ return;
178
+ }
179
+ e.log.info("Registering fastify-phone-auth plugin"), r(e, D), e.addHook("onReady", async () => {
180
+ await S(e.config, e.slonik), await b(e);
181
+ });
182
+ });
183
+ //#endregion
184
+ export { m as DEFAULT_CONTACT_METHOD, h as DEFAULT_FLOW_TYPE, g as ERROR_CODES, _ as TWILIO_VERIFY_PLACEHOLDER_CODE, C as consumeCode, T as consumeCodePOST, O as default, E as getPasswordlessRecipeConfig, v as getTwilioClient, D as initPasswordlessRecipe };