├── .babelrc ├── .env.local.example ├── .eslintrc.js ├── .gitignore ├── .vercelignore ├── README.md ├── components ├── App.js ├── InfoBox.js ├── LogIn.js ├── SignUp.js ├── Thing.js ├── ThingsList.js └── UserContext.js ├── lib ├── cookieConfig.js ├── cookieHelper.js ├── fauna │ ├── config.js │ ├── exampleFunctions.js │ └── exampleRoles.js ├── graphql │ ├── localSchema.js │ ├── overrideSchema.js │ ├── remoteSchema.js │ └── schema.js └── graphqlHelper.js ├── package.json ├── pages ├── _app.js ├── api │ └── graphql.js └── index.js ├── scripts ├── config.js ├── createDoc.js ├── createRoles.js ├── faunaSchema.graphql ├── manageKeys.js ├── setup.js ├── updateFunctions.js └── uploadSchema.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | [ 7 | "graphql-tag" 8 | ] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | FAUNADB_ADMIN_SECRET= 2 | FAUNADB_PUBLIC_ACCESS_KEY= -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended" 10 | ], 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 11, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react" 20 | ], 21 | "rules": { 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | .env 33 | 34 | .vercel 35 | 36 | /.idea* -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | scripts -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js, FaunaDB and `httpOnly` Cookie Auth Flow with GraphQL 2 | 3 | The present example explains how to setup an `httpOnly` cookie auth flow with Next.js and Fauna, using Apollo Server and react-query/graphql-request on the client. 4 | 5 | These are some of the features that this setup provides: 6 | 7 | 1. The auth flow lives inside the Fauna dashboard with the help of [User Defined Functions (UDFs)](https://docs.fauna.com/fauna/current/api/graphql/functions) and [User Defined Roles (UDRs)](https://docs.fauna.com/fauna/current/security/roles.html). UDFs and UDRs are one flexible medium offered in Fauna by which you can implement business logic and [Attribute-based Access Control (ABAC)](https://docs.fauna.com/fauna/current/security/abac.html) to any document, function and index in the database. 8 | 2. A GraphQL server using [schema stitching](https://www.apollographql.com/docs/apollo-server/features/schema-stitching/). 9 | 1. This is helpful because it provides the maximum possible flexibility in terms of API integration through GraphQL, given that Fauna doesn't support [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) yet. 10 | 2. In other words, by using schema stitching we can extend our GraphQL endpoint to connect with other APIs or basically run any arbitrary code in-between Fauna requests (similar to a proxy). 11 | 3. This is specially useful if you don't want to have several API endpoints and want to manage everything through GraphQL. 12 | 3. The example also provides a [series of scripts](/examples/with-cookie-auth-fauna-apollo-server/scripts) that can be executed with a single command, that help you manage your database quickly on a day to day basis, from pushing a newly created schema, generating new keys, updating your functions or even creating a whole new database from scratch. This is incredibly useful when getting started in order to fasten things up. 13 | 4. An httpOnly cookie based auth flow. 14 | 5. Token validation on refresh and window focus with [`react-query`](https://github.com/tannerlinsley/react-query#useQuery). This is useful because it keeps the auth state changes in sync with the client, for example if the user token dissapears on the DB (for any reason), it logs out the user in any other client automatically. 15 | 16 | This is an advanced example that assumes a lot of concepts, and it strives to provide the most ample bed from which you can get started with both FaunaDB and Next.js. If you are looking for a simpler approach which doesn't include GraphQL, UDFs or User Defined Roles, and only handles a cookie based authentication plus token validation, be sure to check out the example [with-cookie-auth-fauna](https://github.com/vercel/next.js/tree/canary/examples/with-cookie-auth-fauna). 17 | 18 | ## Demo 19 | 20 | [https://with-graphql-faunadb-cookie-auth.now.sh/](https://with-graphql-faunadb-cookie-auth.now.sh/) 21 | 22 | ## How to use 23 | 24 | ### Using `create-next-app` 25 | 26 | Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: 27 | 28 | ```bash 29 | npx create-next-app --example with-cookie-auth-fauna-apollo-server with-cookie-auth-fauna-apollo-server-app 30 | # or 31 | yarn create next-app --example with-cookie-auth-fauna-apollo-server with-cookie-auth-fauna-apollo-server-app 32 | ``` 33 | 34 | ### Download manually 35 | 36 | Download the example: 37 | 38 | ```bash 39 | curl https://codeload.github.com/vercel/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/with-cookie-auth-fauna-apollo-server 40 | cd with-cookie-auth-fauna-apollo-server 41 | ``` 42 | 43 | Install it and run: 44 | 45 | ```bash 46 | npm install 47 | npm run dev 48 | # or 49 | yarn 50 | yarn dev 51 | ``` 52 | 53 | Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). 54 | 55 | ### Run locally 56 | 57 | 1. Create an account on [Fauna](https://fauna.com/) 58 | 2. In the [FaunaDB Console](https://dashboard.fauna.com/), click "New Database". Name it whatever you like and click "Save". 59 | 3. Now go to "Security" and click "New Key". Let's create an "Admin" key, name it whatever you want. 60 | 4. Copy the newly created "Admin" key and create an `.env.local` file. Paste the key along the name `FAUNADB_ADMIN_SECRET`. 61 | 5. On you console, while positioned on the root folder, execute: 62 | 63 | ``` 64 | node -e 'require("./scripts/setup.js").full()' 65 | ``` 66 | 67 | This will create all the roles, functions and lastly a public key necessary to connect to the DB securely. 68 | 69 | 6. Copy the last line of the previous command into the `.env.local` file. 70 | 7. (Optional) Delete the admin key both from the [FaunaDB Console](https://dashboard.fauna.com/) and the `.env.local` file. 71 | 8. Run `yarn && yarn dev` 72 | 73 | ### Deploy on Vercel 74 | 75 | You can deploy this app to the cloud with [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). 76 | 77 | #### Deploy Your Local Project 78 | 79 | To deploy your local project to Vercel, push it to GitHub/GitLab/Bitbucket and [import to Vercel](https://vercel.com/import/git?utm_source=github&utm_medium=readme&utm_campaign=next-example). 80 | 81 | **Important**: When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file. 82 | -------------------------------------------------------------------------------- /components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function App({ children }) { 4 | return ( 5 |
6 | {children} 7 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /components/InfoBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function InfoBox({ children }) { 4 | return ( 5 |
6 | 13 | {children} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/LogIn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMutation } from 'react-query'; 3 | import { request } from 'graphql-request'; 4 | 5 | import { user } from './UserContext'; 6 | 7 | const LOGIN_USER = ` 8 | mutation loginUser($data: LoginUserInput!) { 9 | loginUser(data: $data) { 10 | userId 11 | userToken 12 | } 13 | } 14 | `; 15 | 16 | export default function LogIn() { 17 | const { setId } = user(); 18 | const [loginUser, { status: loginStatus }] = useMutation( 19 | (variables) => { 20 | return request('/api/graphql', LOGIN_USER, variables); 21 | }, 22 | { 23 | onSuccess: (data) => { 24 | setId(data.loginUser.userId); 25 | localStorage.setItem('userId', data.loginUser.userId); 26 | }, 27 | onError: (err) => { 28 | console.log(err.message); 29 | }, 30 | } 31 | ); 32 | 33 | const handleSubmit = async (event) => { 34 | event.preventDefault(); 35 | const form = event.target; 36 | const formData = new window.FormData(form); 37 | const email = formData.get('email'); 38 | const password = formData.get('password'); 39 | 40 | await loginUser({ 41 | data: { 42 | email, 43 | password, 44 | }, 45 | }); 46 | 47 | form.reset(); 48 | }; 49 | 50 | return ( 51 |
52 |

LogIn

53 | 54 | 55 | 58 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /components/SignUp.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMutation } from 'react-query'; 3 | import { request } from 'graphql-request'; 4 | 5 | import { user } from './UserContext'; 6 | 7 | const SIGNUP_USER = ` 8 | mutation signupUser($data: CreateUserInput!) { 9 | signupUser(data: $data) { 10 | userId 11 | userToken 12 | } 13 | } 14 | `; 15 | 16 | export default function SignUp({ setIsUserLoggedIn }) { 17 | const { setId } = user(); 18 | const [signupUser, { status: signupStatus }] = useMutation( 19 | (variables) => { 20 | return request('/api/graphql', SIGNUP_USER, variables); 21 | }, 22 | { 23 | onSuccess: (data) => { 24 | setId(data.signupUser.userId); 25 | localStorage.setItem('userId', data.signupUser.userId); 26 | }, 27 | onError: (err) => { 28 | console.log(err.message); 29 | }, 30 | } 31 | ); 32 | 33 | const handleSubmit = async (event) => { 34 | event.preventDefault(); 35 | const form = event.target; 36 | const formData = new window.FormData(form); 37 | const email = formData.get('email'); 38 | const password = formData.get('password'); 39 | form.reset(); 40 | 41 | await signupUser({ 42 | data: { 43 | email, 44 | password, 45 | role: 'FREE_USER', 46 | }, 47 | }); 48 | }; 49 | 50 | return ( 51 |
52 |

SignUp

53 | 54 | 55 | 58 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /components/Thing.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useMutation, queryCache } from 'react-query'; 3 | import { request } from 'graphql-request'; 4 | 5 | const DELETE_THING = ` 6 | mutation DeleteThing($id: ID!) { 7 | deleteThing(id: $id) { 8 | _id 9 | } 10 | } 11 | `; 12 | 13 | const UPDATE_THING = ` 14 | mutation UpdateThing($id: ID!, $data: ThingInput!) { 15 | updateThing(id: $id, data: $data) { 16 | _id 17 | name 18 | } 19 | } 20 | `; 21 | 22 | export default function Thing({ id, name }) { 23 | const [isDeleting, setIsDeleting] = useState(false); 24 | const [isUpdating, setIsUpdating] = useState(false); 25 | 26 | if (!name) name = empty; 27 | 28 | const [deleteThing, { isLoading: isDeleteThingLoading }] = useMutation( 29 | async (variables) => request('/api/graphql', DELETE_THING, variables), 30 | { 31 | onMutate: ({ id }) => { 32 | queryCache.cancelQueries('findAllThings'); 33 | const previousValue = queryCache.getQueryData('findAllThings'); 34 | /* The value returned from this function will be passed to both the onError 35 | * and onSettled functions in the event of a mutation failure and can be useful 36 | * for rolling back optimistic updates 37 | */ 38 | const newData = { 39 | findAllThings: { 40 | data: previousValue.findAllThings.data.filter((o) => o._id !== id), 41 | }, 42 | }; 43 | 44 | queryCache.setQueryData('findAllThings', newData); 45 | return previousValue; 46 | }, 47 | onSuccess: (data) => { 48 | console.log('Delete success', data); 49 | }, 50 | onError: (err, variables, previousValue) => { 51 | console.log(err); 52 | queryCache.setQueryData('findAllThings', previousValue); 53 | }, 54 | } 55 | ); 56 | 57 | const [updateThing, { isLoading: isUpdateThingLoading }] = useMutation( 58 | async (variables) => request('/api/graphql', UPDATE_THING, variables), 59 | { 60 | onMutate: ({ data, id }) => { 61 | queryCache.cancelQueries('findAllThings'); 62 | const previousValue = queryCache.getQueryData('findAllThings'); 63 | /* The value returned from this function will be passed to both the onError 64 | * and onSettled functions in the event of a mutation failure and can be useful 65 | * for rolling back optimistic updates 66 | */ 67 | const newData = { 68 | findAllThings: { 69 | data: previousValue.findAllThings.data.map((el) => { 70 | if (el._id === id) { 71 | return Object.assign({}, el, { name: data.name }); 72 | } 73 | return el; 74 | }), 75 | }, 76 | }; 77 | queryCache.setQueryData('findAllThings', newData); 78 | return previousValue; 79 | }, 80 | onSuccess: (data) => { 81 | console.log('Update success', data); 82 | }, 83 | onError: (err, variables, previousValue) => { 84 | console.log(err); 85 | queryCache.setQueryData('findAllThings', previousValue); 86 | }, 87 | } 88 | ); 89 | 90 | const handleUpdateThing = async (event) => { 91 | event.preventDefault(); 92 | const form = event.target; 93 | const formData = new window.FormData(form); 94 | const thingName = formData.get('name'); 95 | 96 | await updateThing({ 97 | data: { 98 | name: thingName, 99 | }, 100 | id, 101 | }); 102 | form.reset(); 103 | setIsUpdating(false); 104 | }; 105 | 106 | let fullRender = ( 107 |
  • 108 | setIsUpdating(true)}>{name} 109 | 119 | 126 |
  • 127 | ); 128 | if (!isDeleting && isUpdating) { 129 | fullRender = ( 130 |
  • 131 |
    132 | 133 | 143 | 153 |
    154 | 161 |
  • 162 | ); 163 | } 164 | if (isDeleting && !isUpdating) { 165 | fullRender = ( 166 |
  • 167 | {name} 168 | 178 | 188 | 195 |
  • 196 | ); 197 | } 198 | 199 | return fullRender; 200 | } 201 | -------------------------------------------------------------------------------- /components/ThingsList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Thing from './Thing'; 3 | import { useQuery, useMutation, queryCache } from 'react-query'; 4 | import { request } from 'graphql-request'; 5 | 6 | import { user } from './UserContext'; 7 | 8 | const GET_THINGS = ` 9 | query FindAllThings { 10 | findAllThings { 11 | data { 12 | _id 13 | name 14 | } 15 | } 16 | } 17 | `; 18 | 19 | const CREATE_THING = ` 20 | mutation CreateThing($data: ThingInput!) { 21 | createThing(data: $data) { 22 | _id 23 | name 24 | } 25 | } 26 | `; 27 | 28 | export default function ThingList() { 29 | const { id } = user(); 30 | const [isCreating, setIsCreating] = useState(false); 31 | 32 | const { data: things, isFetching } = useQuery( 33 | ['findAllThings'], 34 | async () => request('/api/graphql', GET_THINGS), 35 | { 36 | onError: (err) => { 37 | console.log(err); 38 | }, 39 | refetchOnWindowFocus: false, 40 | staleTime: Infinity, 41 | cacheTime: Infinity, 42 | } 43 | ); 44 | 45 | const [createThing, { status: createThingStatus }] = useMutation( 46 | async (variables) => request('/api/graphql', CREATE_THING, variables), 47 | { 48 | onMutate: ({ data }) => { 49 | queryCache.cancelQueries('findAllThings'); 50 | const previousValue = queryCache.getQueryData('findAllThings'); 51 | const newThing = { 52 | _id: 'temp-id', 53 | name: data.name, 54 | }; 55 | /* The value returned from this function will be passed to both the onError 56 | * and onSettled functions in the event of a mutation failure and can be useful 57 | * for rolling back optimistic updates 58 | */ 59 | const newData = { 60 | findAllThings: { 61 | data: [...previousValue.findAllThings.data, newThing], 62 | }, 63 | }; 64 | 65 | queryCache.setQueryData('findAllThings', newData); 66 | return previousValue; 67 | }, 68 | onSuccess: ({ createThing }) => { 69 | queryCache.cancelQueries('findAllThings'); 70 | const currentValue = queryCache.getQueryData('findAllThings'); 71 | const thingsWithoutLast = currentValue.findAllThings.data.slice( 72 | 0, 73 | currentValue.findAllThings.data.length - 1 74 | ); 75 | /* To replace the `temp-id` */ 76 | const newLastThing = { 77 | _id: createThing._id, 78 | name: createThing.name, 79 | }; 80 | const newData = { 81 | findAllThings: { 82 | data: [...thingsWithoutLast, newLastThing], 83 | }, 84 | }; 85 | 86 | queryCache.setQueryData('findAllThings', newData); 87 | console.log('Create success', newData); 88 | setIsCreating(false); 89 | }, 90 | onError: (err, variables, previousValue) => { 91 | console.log(err.message); 92 | queryCache.setQueryData('findAllThings', previousValue); 93 | }, 94 | } 95 | ); 96 | 97 | const handleCreateThing = async (event) => { 98 | event.preventDefault(); 99 | const form = event.target; 100 | const formData = new window.FormData(form); 101 | const thingName = formData.get('name'); 102 | 103 | await createThing({ 104 | data: { 105 | owner: { 106 | connect: id, 107 | }, 108 | name: thingName, 109 | }, 110 | }); 111 | form.reset(); 112 | }; 113 | 114 | let thingsList; 115 | if (things) { 116 | thingsList = [...things.findAllThings.data] 117 | .reverse() 118 | .map((thing) => ( 119 | 120 | )); 121 | } 122 | 123 | return ( 124 |
    125 |
    126 | {isCreating ? ( 127 | <> 128 |
    129 | 135 | 142 | 149 |
    150 | 151 | ) : ( 152 | 155 | )} 156 |
    157 |

    List of things (click the list item text to update)

    158 | 159 | 166 |
    167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /components/UserContext.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useContext } from 'react'; 2 | 3 | const UserContext = createContext(); 4 | 5 | export const UserContextProvider = ({ children }) => { 6 | const [id, setId] = useState(''); 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | 14 | export const user = () => useContext(UserContext); 15 | -------------------------------------------------------------------------------- /lib/cookieConfig.js: -------------------------------------------------------------------------------- 1 | export const SECRET_COOKIE_NAME = 'custom_cookie'; 2 | 3 | export const setCookieConfig = { 4 | sameSite: 'lax', 5 | secure: process.env.NODE_ENV === 'production', 6 | maxAge: 14 * 24 * 60 * 60 * 1000, 7 | httpOnly: true, 8 | path: '/', 9 | }; 10 | 11 | export const unsetCookieConfig = { 12 | sameSite: 'lax', 13 | secure: process.env.NODE_ENV === 'production', 14 | maxAge: -1, 15 | httpOnly: true, 16 | path: '/', 17 | }; 18 | -------------------------------------------------------------------------------- /lib/cookieHelper.js: -------------------------------------------------------------------------------- 1 | import { serialize } from 'cookie'; 2 | 3 | /** 4 | * This sets `cookie` on `res` object 5 | * I extended this from an example in Next.js 6 | */ 7 | const cookie = (res, name, value, options = {}) => { 8 | if (typeof value !== 'object' && typeof value !== 'string') { 9 | throw new TypeError('cookies must be an object or a string'); 10 | } 11 | 12 | if ('maxAge' in options) { 13 | options.expires = new Date(Date.now() + options.maxAge); 14 | options.maxAge /= 1000; 15 | } 16 | 17 | if (typeof value === 'object') { 18 | let cookieArray = Object.keys(value); 19 | 20 | for (let i = 0; i < cookieArray.length; i++) { 21 | let name = cookieArray[i]; 22 | cookieArray[i] = serialize(name, value[name], options); 23 | } 24 | 25 | res.setHeader('Set-Cookie', cookieArray); 26 | } 27 | 28 | if (typeof value === 'string') { 29 | const cookieValue = String(value); 30 | 31 | res.setHeader('Set-Cookie', serialize(name, String(cookieValue), options)); 32 | } 33 | }; 34 | 35 | /** 36 | * Adds `cookie` function on `res.cookie` to set cookies for response 37 | */ 38 | const cookieSetter = (handler) => (req, res) => { 39 | res.setCookie = (name, value, options) => cookie(res, name, value, options); 40 | 41 | return handler(req, res); 42 | }; 43 | 44 | export default cookieSetter; 45 | -------------------------------------------------------------------------------- /lib/fauna/config.js: -------------------------------------------------------------------------------- 1 | import faunadb from 'faunadb'; 2 | 3 | export const SECRET_COOKIE_NAME = 'custom_cookie'; 4 | 5 | // Used for any authed requests. 6 | export const faunaClient = (secret) => 7 | new faunadb.Client({ 8 | secret, 9 | }); 10 | 11 | export const setCookieConfig = { 12 | sameSite: 'lax', 13 | secure: process.env.NODE_ENV === 'production', 14 | maxAge: 14 * 24 * 60 * 60 * 1000, 15 | httpOnly: true, 16 | path: '/', 17 | }; 18 | 19 | export const unsetCookieConfig = { 20 | sameSite: 'lax', 21 | secure: process.env.NODE_ENV === 'production', 22 | maxAge: -1, 23 | httpOnly: true, 24 | path: '/', 25 | }; 26 | -------------------------------------------------------------------------------- /lib/fauna/exampleFunctions.js: -------------------------------------------------------------------------------- 1 | // create_user 2 | Query( 3 | Lambda( 4 | ['input'], 5 | Create(Collection('User'), { 6 | data: { 7 | email: Select('email', Var('input')), 8 | role: Select('role', Var('input')), 9 | }, 10 | credentials: { password: Select('password', Var('input')) }, 11 | }) 12 | ) 13 | ); 14 | 15 | // login_user 16 | Query( 17 | Lambda( 18 | ['input'], 19 | Select( 20 | 'secret', 21 | Login(Match(Index('unique_User_email'), Select('email', Var('input'))), { 22 | password: Select('password', Var('input')), 23 | ttl: TimeAdd(Now(), 14, 'days'), 24 | }) 25 | ) 26 | ) 27 | ); 28 | 29 | // logout_user 30 | Query(Lambda('_', Logout(true))); 31 | 32 | // signup_user; 33 | Query( 34 | Lambda( 35 | ['input'], 36 | Do( 37 | Call(Function('create_user'), Var('input')), 38 | Call(Function('login_user'), Var('input')) 39 | ) 40 | ) 41 | ); 42 | 43 | // validate_token 44 | Query(Lambda(['token'], Not(IsNull(KeyFromSecret(Var('token')))))); 45 | -------------------------------------------------------------------------------- /lib/fauna/exampleRoles.js: -------------------------------------------------------------------------------- 1 | // fnc_role_create_user role 2 | CreateRole({ 3 | name: 'fnc_role_create_user', 4 | privileges: [ 5 | { 6 | resource: Collection('User'), 7 | actions: { 8 | read: true, 9 | create: Query( 10 | Lambda( 11 | 'values', 12 | Equals(Select(['data', 'role'], Var('values')), 'FREE_USER') 13 | ) 14 | ), 15 | }, 16 | }, 17 | ], 18 | }); 19 | 20 | // fnc_role_login_user role 21 | CreateRole({ 22 | name: 'fnc_role_login_user', 23 | privileges: [ 24 | { 25 | resource: Index('unique_User_email'), 26 | actions: { 27 | unrestricted_read: true, 28 | }, 29 | }, 30 | ], 31 | }); 32 | 33 | // fnc_role_logout_user role 34 | CreateRole({ 35 | name: 'fnc_role_logout_user', 36 | privileges: [ 37 | { 38 | resource: Ref('tokens'), 39 | actions: { 40 | create: true, 41 | read: true, 42 | }, 43 | }, 44 | ], 45 | }); 46 | 47 | // fnc_role_signup_user role 48 | CreateRole({ 49 | name: 'fnc_role_signup_user', 50 | privileges: [ 51 | { 52 | resource: Function('create_user'), 53 | actions: { 54 | call: true, 55 | }, 56 | }, 57 | { 58 | resource: Function('login_user'), 59 | actions: { 60 | call: true, 61 | }, 62 | }, 63 | ], 64 | }); 65 | 66 | // fnc_role_validate_token role 67 | CreateRole({ 68 | name: 'fnc_role_validate_token', 69 | privileges: [ 70 | { 71 | resource: Ref('tokens'), 72 | actions: { 73 | read: true, 74 | }, 75 | }, 76 | ], 77 | }); 78 | 79 | // free_user role 80 | CreateRole({ 81 | name: 'free_user', 82 | privileges: [ 83 | { 84 | resource: Collection('User'), 85 | actions: { 86 | read: Query(Lambda('userRef', Equals(Identity(), Var('userRef')))), 87 | write: Query( 88 | Lambda( 89 | ['_', 'newData', 'userRef'], 90 | And( 91 | Equals(Identity(), Var('userRef')), 92 | Equals('FREE_USER', Select(['data', 'role'], Var('newData'))) 93 | ) 94 | ) 95 | ), 96 | }, 97 | }, 98 | { 99 | resource: Function('validate_token'), 100 | actions: { 101 | call: true, 102 | }, 103 | }, 104 | { 105 | resource: Function('logout_user'), 106 | actions: { 107 | call: true, 108 | }, 109 | }, 110 | ], 111 | membership: [ 112 | { 113 | resource: Collection('User'), 114 | predicate: Query( 115 | Lambda( 116 | 'userRef', 117 | Or(Equals(Select(['data', 'role'], Get(Var('userRef'))), 'FREE_USER')) 118 | ) 119 | ), 120 | }, 121 | ], 122 | }); 123 | 124 | // public role 125 | CreateRole({ 126 | name: 'public', 127 | privileges: [ 128 | { 129 | resource: Function('signup_user'), 130 | actions: { 131 | call: true, 132 | }, 133 | }, 134 | { 135 | resource: Function('login_user'), 136 | actions: { 137 | call: true, 138 | }, 139 | }, 140 | ], 141 | }); 142 | -------------------------------------------------------------------------------- /lib/graphql/localSchema.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-micro'; 2 | import cookie from 'cookie'; 3 | 4 | import { SECRET_COOKIE_NAME } from '../cookieConfig'; 5 | 6 | const localTypeDefs = gql` 7 | type Query { 8 | validCookie: Boolean! 9 | } 10 | `; 11 | 12 | const localResolvers = { 13 | Query: { 14 | validCookie: async (root, args, context) => { 15 | const { token } = context; 16 | const tkValue = await token(context.req, context.res); 17 | /* The context runs before every query or mutation, and since 18 | we are already validating the token in tokenValidation(), 19 | we only use validToken to dinamically pull the cookie 20 | on browser reload. 21 | There's no need to unset or set the cookie here as that 22 | is done in the context. */ 23 | let customCookie; 24 | if (context.req.headers.cookie) { 25 | const parsedCookies = cookie.parse(context.req.headers.cookie); 26 | customCookie = parsedCookies[SECRET_COOKIE_NAME]; 27 | } 28 | if (tkValue || customCookie) { 29 | console.log( 30 | ' query -- validCookie -- found token/cookie', 31 | tkValue || customCookie 32 | ); 33 | return true; 34 | } 35 | console.log(' query -- validCookie -- no valid customCookie or token'); 36 | return false; 37 | }, 38 | }, 39 | }; 40 | 41 | export { localTypeDefs, localResolvers }; 42 | -------------------------------------------------------------------------------- /lib/graphql/overrideSchema.js: -------------------------------------------------------------------------------- 1 | import { UserInputError, ApolloError } from 'apollo-server-micro'; 2 | import chalk from 'chalk'; 3 | 4 | import { 5 | SECRET_COOKIE_NAME, 6 | unsetCookieConfig, 7 | setCookieConfig, 8 | } from '../cookieConfig'; 9 | import { delegate } from '../graphqlHelper'; 10 | 11 | const createOverrideResolvers = (remoteExecutableSchema) => ({ 12 | Mutation: { 13 | loginUser: async (root, args, context, info) => { 14 | const params = [args, context, info]; 15 | const { setCookie } = context.res; 16 | 17 | if (!args.data || !args.data.email || !args.data.password) { 18 | throw new UserInputError('Missing input data', { 19 | invalidArgs: Object.keys(args), 20 | }); 21 | } 22 | 23 | const data = await delegate( 24 | ...params, 25 | { remoteExecutableSchema }, 26 | 'mutation', 27 | 'loginUser' 28 | ); 29 | 30 | if (data.userToken) { 31 | console.log( 32 | chalk.cyan(' mutation loginUser -- setting custom cookie') 33 | ); 34 | setCookie(SECRET_COOKIE_NAME, data.userToken, setCookieConfig); 35 | return { 36 | userId: data.userId, 37 | }; 38 | } 39 | 40 | throw new ApolloError('User token not found'); 41 | }, 42 | signupUser: async (root, args, context, info) => { 43 | const params = [args, context, info]; 44 | const { setCookie } = context.res; 45 | 46 | if ( 47 | !args.data || 48 | !args.data.email || 49 | !args.data.password || 50 | !args.data.role 51 | ) { 52 | throw new UserInputError('Missing input data', { 53 | invalidArgs: Object.keys(args), 54 | }); 55 | } 56 | 57 | const data = await delegate( 58 | ...params, 59 | { remoteExecutableSchema }, 60 | 'mutation', 61 | 'signupUser' 62 | ); 63 | 64 | if (data.userToken) { 65 | console.log( 66 | chalk.cyan(' mutation signupUser -- setting custom cookie') 67 | ); 68 | setCookie(SECRET_COOKIE_NAME, data.userToken, setCookieConfig); 69 | return { 70 | userId: data.userId, 71 | }; 72 | } 73 | 74 | throw new ApolloError('User token not found'); 75 | }, 76 | logoutUser: async (root, args, context, info) => { 77 | const params = [args, context, info]; 78 | const { setCookie } = context.res; 79 | 80 | // Logging out in Fauna means deleting any user specific ABAC tokens 81 | const data = await delegate( 82 | ...params, 83 | { remoteExecutableSchema }, 84 | 'mutation', 85 | 'logoutUser' 86 | ); 87 | if (data === 'already logged out') return true; 88 | if (data === true) { 89 | console.log(' mutation logoutUser -- Successful. Deleting cookie'); 90 | if (data) { 91 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig); 92 | return data; 93 | } 94 | } 95 | console.log(' mutation logoutUser -- Unexpected error'); 96 | return data; 97 | }, 98 | }, 99 | }); 100 | 101 | export { createOverrideResolvers }; 102 | -------------------------------------------------------------------------------- /lib/graphql/remoteSchema.js: -------------------------------------------------------------------------------- 1 | // To get this file go to https://dashboard.fauna.com 2 | // Select your database 3 | // Go to "Graphql" 4 | // Click on "Schema" (right side) 5 | // Then download in SDL format 6 | // Then when you paste it be careful with backtics (`) as it messes the template literal 7 | const remoteTypeDefs = ` 8 | directive @embedded on OBJECT 9 | directive @collection(name: String!) on OBJECT 10 | directive @index(name: String!) on FIELD_DEFINITION 11 | directive @resolver( 12 | name: String 13 | paginated: Boolean! = false 14 | ) on FIELD_DEFINITION 15 | directive @relation(name: String) on FIELD_DEFINITION 16 | directive @unique(index: String) on FIELD_DEFINITION 17 | input CreateUserInput { 18 | email: String! 19 | password: String! 20 | role: UserRole! 21 | } 22 | 23 | scalar Date 24 | 25 | type LoginRes { 26 | userToken: String 27 | userId: String! 28 | } 29 | 30 | # 'LoginRes' input values 31 | input LoginResInput { 32 | userToken: String 33 | userId: String! 34 | } 35 | 36 | input LoginUserInput { 37 | email: String! 38 | password: String! 39 | } 40 | 41 | # The 'Long' scalar type represents non-fractional signed whole numeric values. 42 | # Long can represent values between -(2^63) and 2^63 - 1. 43 | scalar Long 44 | 45 | type Mutation { 46 | logoutUser: Boolean! 47 | # Update an existing document in the collection of 'User' 48 | updateUser( 49 | # The 'User' document's ID 50 | id: ID! 51 | # 'User' input values 52 | data: UserInput! 53 | ): User 54 | # Create a new document in the collection of 'Thing' 55 | createThing( 56 | # 'Thing' input values 57 | data: ThingInput! 58 | ): Thing! 59 | createUser(data: CreateUserInput!): User! 60 | # Delete an existing document in the collection of 'Thing' 61 | deleteThing( 62 | # The 'Thing' document's ID 63 | id: ID! 64 | ): Thing 65 | loginUser(data: LoginUserInput!): LoginRes! 66 | # Update an existing document in the collection of 'Thing' 67 | updateThing( 68 | # The 'Thing' document's ID 69 | id: ID! 70 | # 'Thing' input values 71 | data: ThingInput! 72 | ): Thing 73 | # Delete an existing document in the collection of 'User' 74 | deleteUser( 75 | # The 'User' document's ID 76 | id: ID! 77 | ): User 78 | signupUser(data: CreateUserInput!): LoginRes! 79 | } 80 | 81 | type Query { 82 | # Find a document from the collection of 'User' by its id. 83 | findUserByID( 84 | # The 'User' document's ID 85 | id: ID! 86 | ): User 87 | # Find a document from the collection of 'Thing' by its id. 88 | findThingByID( 89 | # The 'Thing' document's ID 90 | id: ID! 91 | ): Thing 92 | findAllThings( 93 | # The number of items to return per page. 94 | _size: Int 95 | # The pagination cursor. 96 | _cursor: String 97 | ): ThingPage! 98 | } 99 | 100 | type Thing { 101 | # The document's ID. 102 | _id: ID! 103 | # The document's timestamp. 104 | _ts: Long! 105 | owner: User! 106 | name: String 107 | } 108 | 109 | # 'Thing' input values 110 | input ThingInput { 111 | owner: ThingOwnerRelation 112 | name: String 113 | } 114 | 115 | # Allow manipulating the relationship between the types 'Thing' and 'User' using the field 'Thing.owner'. 116 | input ThingOwnerRelation { 117 | # Create a document of type 'User' and associate it with the current document. 118 | create: UserInput 119 | # Connect a document of type 'User' with the current document using its ID. 120 | connect: ID 121 | } 122 | 123 | # The pagination object for elements of type 'Thing'. 124 | type ThingPage { 125 | # The elements of type 'Thing' in this page. 126 | data: [Thing]! 127 | # A cursor for elements coming after the current page. 128 | after: String 129 | # A cursor for elements coming before the current page. 130 | before: String 131 | } 132 | 133 | scalar Time 134 | 135 | type User { 136 | email: String! 137 | role: UserRole! 138 | # The document's ID. 139 | _id: ID! 140 | things( 141 | # The number of items to return per page. 142 | _size: Int 143 | # The pagination cursor. 144 | _cursor: String 145 | ): ThingPage! 146 | # The document's timestamp. 147 | _ts: Long! 148 | } 149 | 150 | # 'User' input values 151 | input UserInput { 152 | things: UserThingsRelation 153 | email: String! 154 | role: UserRole! 155 | } 156 | 157 | enum UserRole { 158 | FREE_USER 159 | } 160 | 161 | # Allow manipulating the relationship between the types 'User' and 'Thing'. 162 | input UserThingsRelation { 163 | # Create one or more documents of type 'Thing' and associate them with the current document. 164 | create: [ThingInput] 165 | # Connect one or more documents of type 'Thing' with the current document using their IDs. 166 | connect: [ID] 167 | # Disconnect the given documents of type 'Thing' from the current document using their IDs. 168 | disconnect: [ID] 169 | } 170 | `; 171 | 172 | export { remoteTypeDefs }; 173 | -------------------------------------------------------------------------------- /lib/graphql/schema.js: -------------------------------------------------------------------------------- 1 | import { 2 | mergeSchemas, 3 | makeExecutableSchema, 4 | makeRemoteExecutableSchema, 5 | addSchemaLevelResolveFunction, 6 | } from 'apollo-server-micro'; 7 | import { setContext } from 'apollo-link-context'; 8 | import { createHttpLink } from 'apollo-link-http'; 9 | import cookie from 'cookie'; 10 | import chalk from 'chalk'; 11 | 12 | import { remoteTypeDefs } from './remoteSchema'; 13 | import { localTypeDefs, localResolvers } from './localSchema'; 14 | import { createOverrideResolvers } from './overrideSchema'; 15 | import { SECRET_COOKIE_NAME } from '../cookieConfig'; 16 | 17 | /* We create the link from scratch because we need to use 18 | `concat` later on */ 19 | const httpLink = new createHttpLink({ 20 | uri: 'https://graphql.fauna.com/graphql', 21 | fetch, 22 | }); 23 | 24 | /* `setContext` runs before any remote request by `delegateToSchema`, 25 | this is due to `contextlink.concat`. 26 | In other words, it runs before delegating to Fauna. 27 | In general, this function is in charge of deciding which token to use 28 | in the headers, the public one or the one from the user. For example, 29 | during login or signup it will always default to the public token 30 | because it will not find any token in the headers from `previousContext` */ 31 | const contextlink = setContext((_, previousContext) => { 32 | console.log(chalk.gray('⚙️ ') + chalk.cyan('schema -- setContext')); 33 | let token = process.env.FAUNADB_PUBLIC_ACCESS_KEY; // public token 34 | const { req } = previousContext.graphqlContext; 35 | if (!req.headers.cookie) 36 | console.log( 37 | ' schema -- setContext -- Setting headers with default public token.' 38 | ); 39 | if (req.headers.cookie) { 40 | const parsedCookie = cookie.parse(req.headers.cookie); 41 | const customCookie = parsedCookie[SECRET_COOKIE_NAME]; 42 | if (customCookie) { 43 | console.log( 44 | ' schema -- setContext -- Found custom cookie. Re-setting headers with it.' 45 | ); 46 | token = customCookie; 47 | } 48 | } 49 | /* `token` is the public one always, except for when 50 | we find a `customCookie` */ 51 | return { 52 | headers: { 53 | Authorization: `Bearer ${token}`, 54 | }, 55 | }; 56 | }); 57 | 58 | /* Then we finally create the link to use to handle the remote schemas */ 59 | const link = contextlink.concat(httpLink); 60 | 61 | /* We'll not be using `introspectSchema` in order to avoid 62 | a second trip to the Fauna server. 63 | The remote schema was downloaded directly from Fauna 64 | and saved to local file (remoteTypeDefs). */ 65 | const remoteExecutableSchema = makeRemoteExecutableSchema({ 66 | schema: remoteTypeDefs, 67 | link, 68 | }); 69 | 70 | /* `localExecutableSchema` is used to implement functionality 71 | exclusive only to the client. */ 72 | const localExecutableSchema = makeExecutableSchema({ 73 | typeDefs: localTypeDefs, 74 | resolvers: localResolvers, 75 | }); 76 | 77 | const schema = mergeSchemas({ 78 | schemas: [remoteExecutableSchema, localExecutableSchema], 79 | /* `createOverrideResolvers` helps, as it names implies, 80 | to override UDFs present in Fauna Graphql endpoint. 81 | These overrides will run before hitting Fauna's servers. 82 | Refer back to setContext for the function that sets 83 | the headers before connecting to Fauna. */ 84 | resolvers: createOverrideResolvers( 85 | remoteExecutableSchema, 86 | localExecutableSchema 87 | ), 88 | }); 89 | 90 | /* Runs after `context` and before `setContext */ 91 | const rootResolveFunction = async (parent, args, context, info) => { 92 | console.log( 93 | chalk.yellow('⚡️ ') + 94 | chalk.cyan( 95 | 'root resolver -- to run:', 96 | `${info.path.typename} ${info.path.key}` 97 | ) 98 | ); 99 | }; 100 | 101 | addSchemaLevelResolveFunction(schema, rootResolveFunction); 102 | 103 | export default schema; 104 | -------------------------------------------------------------------------------- /lib/graphqlHelper.js: -------------------------------------------------------------------------------- 1 | import faunadb from 'faunadb'; 2 | 3 | import { SECRET_COOKIE_NAME, unsetCookieConfig } from './cookieConfig'; 4 | 5 | // Used for any authed requests. 6 | export const faunaClient = (secret) => 7 | new faunadb.Client({ 8 | secret, 9 | }); 10 | 11 | export const delegate = ( 12 | args, 13 | context, 14 | info, 15 | schemaObj, 16 | operation, 17 | fieldName 18 | ) => { 19 | const { cookie: setCookie } = context.res; 20 | // To get the schema var name as a string to be used in the console log 21 | // in case of error 22 | const varToString = (varObj) => Object.keys(varObj)[0]; 23 | return info.mergeInfo 24 | .delegateToSchema({ 25 | schema: schemaObj[Object.keys(schemaObj)[0]], 26 | operation, 27 | fieldName, 28 | args, 29 | context, 30 | info, 31 | }) 32 | .catch((error) => { 33 | if (fieldName !== 'logoutUser') { 34 | console.log( 35 | `${operation} (${fieldName}) - Delegation to ${varToString( 36 | schemaObj 37 | )} failed --`, 38 | error.message 39 | ); 40 | return error; 41 | } else { 42 | if (error.message === 'Invalid database secret.') { 43 | console.log( 44 | ' mutation logoutUser -- Already logged out -- Deleting cookie' 45 | ); 46 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig); 47 | return 'already logged out'; 48 | } 49 | if ( 50 | error.message === 'Insufficient privileges to perform the action.' 51 | ) { 52 | console.log( 53 | ' mutation logoutUser -- Already logged out and using public token' 54 | ); 55 | return 'already logged out'; 56 | } 57 | console.log( 58 | " mutation logoutUser -- Couldn't log user out --", 59 | error.message 60 | ); 61 | return error.message; 62 | } 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-cookie-auth-fauna-apollo-server", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "next", 6 | "build": "next build", 7 | "start": "next start" 8 | }, 9 | "dependencies": { 10 | "apollo-link-context": "^1.0.20", 11 | "apollo-link-http": "^1.5.17", 12 | "apollo-server-micro": "^2.18.2", 13 | "cookie": "^0.4.1", 14 | "faunadb": "^3.0.1", 15 | "graphql": "^15.3.0", 16 | "graphql-request": "^3.1.0", 17 | "next": "9.5.5", 18 | "react": "^16.14.0", 19 | "react-dom": "^16.14.0", 20 | "react-query": "^2.23.1", 21 | "react-query-devtools": "^2.6.0" 22 | }, 23 | "devDependencies": { 24 | "babel-plugin-graphql-tag": "^3.1.0", 25 | "dotenv": "^8.2.0", 26 | "eslint": "^7.11.0", 27 | "eslint-plugin-react": "^7.21.4", 28 | "request": "^2.88.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReactQueryDevtools } from 'react-query-devtools'; 3 | import { UserContextProvider } from '../components/UserContext'; 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default MyApp; 15 | -------------------------------------------------------------------------------- /pages/api/graphql.js: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-micro'; 2 | import cookie from 'cookie'; 3 | import faunadb from 'faunadb'; 4 | import chalk from 'chalk'; 5 | 6 | import cookieSetter from '../../lib/cookieHelper'; 7 | import { SECRET_COOKIE_NAME, unsetCookieConfig } from '../../lib/cookieConfig'; 8 | import { faunaClient } from '../../lib/graphqlHelper'; 9 | import schema from '../../lib/graphql/schema'; 10 | 11 | const q = faunadb.query; 12 | 13 | const tokenValidation = async (req, res) => { 14 | /* Since we need to constantly validate the user token 15 | then it makes sense to do it before every resolver, and 16 | save either an empty string or a valid token in the context. 17 | `tokenValidation` only unsets the cookie in case it's not valid. */ 18 | console.log(chalk.gray('⚙️ ') + chalk.cyan('context -- tokenValidation')); 19 | const { setCookie } = res; 20 | let isTokenValid, token; 21 | if (req.headers.cookie) { 22 | const parsedCookies = cookie.parse(req.headers.cookie); 23 | const customCookie = parsedCookies[SECRET_COOKIE_NAME]; 24 | if (customCookie) { 25 | try { 26 | isTokenValid = await faunaClient(customCookie).query( 27 | q.Call(q.Function('validate_token'), customCookie) 28 | ); 29 | if (isTokenValid === true) { 30 | token = customCookie; 31 | console.log( 32 | ' context -- tokenValidation --', 33 | chalk.green('token is valid!') 34 | ); 35 | /* Don't reset the cookie with `setCookie`, as it would restart its maxAge time. 36 | Setting up the cookie should only be done on login or signup. */ 37 | } else { 38 | token = ''; 39 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig); 40 | } 41 | } catch (err) { 42 | console.log( 43 | chalk.red(' context -- tokenValidation failed, clearing cookie'), 44 | err.message 45 | ); 46 | token = ''; 47 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig); 48 | } 49 | } 50 | if (!customCookie) { 51 | console.log(chalk.red(' context -- tokenValidation, no cookie found')); 52 | token = ''; 53 | } 54 | } 55 | return token; 56 | }; 57 | 58 | const apolloServer = new ApolloServer({ 59 | schema, 60 | /* The context is recalculated every time a resolver runs, 61 | it runs even before `setContext`. We can also pass it down 62 | to all resolvers and resolve it there */ 63 | context: async (ctx) => ({ 64 | token: tokenValidation, 65 | ...ctx, 66 | }), 67 | introspection: !(process.env.NODE_ENV === 'production'), 68 | playground: !(process.env.NODE_ENV === 'production'), 69 | }); 70 | 71 | export const config = { 72 | api: { 73 | bodyParser: false, 74 | }, 75 | }; 76 | 77 | const handler = apolloServer.createHandler({ path: '/api/graphql' }); 78 | 79 | export default cookieSetter(handler); 80 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useMutation, useQuery, queryCache } from 'react-query'; 3 | import { request } from 'graphql-request'; 4 | 5 | import App from '../components/App'; 6 | import InfoBox from '../components/InfoBox'; 7 | import SignUp from '../components/SignUp'; 8 | import LogIn from '../components/LogIn'; 9 | import ThingList from '../components/ThingsList'; 10 | import { user } from '../components/UserContext'; 11 | 12 | const LOGOUT_USER = ` 13 | mutation logoutUser { 14 | logoutUser 15 | } 16 | `; 17 | 18 | const VALIDATE_COOKIE = ` 19 | query validateCookie { 20 | validCookie 21 | } 22 | `; 23 | 24 | const IndexPage = () => { 25 | const { id, setId } = user(); 26 | 27 | const [logoutUser, { status: logoutStatus }] = useMutation( 28 | () => request('/api/graphql', LOGOUT_USER), 29 | { 30 | onSuccess: () => { 31 | queryCache.clear(); 32 | localStorage.removeItem('userId'); 33 | setId(''); 34 | console.log('Logout success'); 35 | }, 36 | } 37 | ); 38 | 39 | // Should only validate when user is logged in and every 3 seconds 40 | const { status: validateStatus, isFetching: isValidateFetching } = useQuery( 41 | ['validCookie'], 42 | async () => { 43 | // debugger; 44 | return request('/api/graphql', VALIDATE_COOKIE); 45 | }, 46 | { 47 | onSuccess: (data) => { 48 | if (data.validCookie === true) { 49 | /* No need to do anything else, the `userId` is handled in the `useEffect` */ 50 | console.log('Validation success'); 51 | } else { 52 | console.log('Custom cookie not valid'); 53 | logoutUser(); 54 | } 55 | }, 56 | onError: (err) => { 57 | console.log(err); 58 | }, 59 | refetchOnMount: false, 60 | refetchOnWindowFocus: true, 61 | } 62 | ); 63 | 64 | /* Only runs once to pull the id from `localStorage` */ 65 | useEffect(() => { 66 | const userId = localStorage.getItem('userId'); 67 | userId ? setId(userId) : setId(''); 68 | }, []); 69 | 70 | return ( 71 | 72 | 73 | This example shows how to signup/login and setup an httpOnly cookie 74 | while also validating said cookie on focus and on every initial render. 75 | 76 | 77 | Try duplicating the tab, logging out in the new one, and then navigating 78 | back to the original. It should automatically logout, syncing both tabs. 79 | 80 | Lookout for "custom_cookie" in the devtools 81 | 82 | Try to log in with: 83 |
    84 | email: 123@example.com 85 |
    86 | password: 123 87 |
    88 | 89 | Is cookie being validated?{' '} 90 | 91 | {validateStatus === 'loading' || isValidateFetching 92 | ? 'TRUE' 93 | : 'FALSE'} 94 | 95 | 96 | 97 | Is user logged in? {id ? 'TRUE' : 'FALSE'} 98 | 99 | {!id ? null : ( 100 |
    101 |

    LogOut

    102 | 110 | 111 |
    112 | )} 113 | {id ? null : ( 114 | <> 115 | 116 | 117 | 118 | )} 119 | 130 |
    131 | ); 132 | }; 133 | 134 | export default IndexPage; 135 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.env.local' }); 2 | const { Client } = require('faunadb'); 3 | const chalk = require('chalk'); 4 | 5 | const ENV_NAME = 'FAUNADB_ADMIN_SECRET'; 6 | 7 | function validateThenRun(fnc) { 8 | if (!process.env[ENV_NAME]) { 9 | console.log( 10 | chalk.yellow(`Required ${ENV_NAME} enviroment variable not found.`) 11 | ); 12 | process.exit(1); 13 | } 14 | 15 | if (process.env[ENV_NAME]) { 16 | return fnc(); 17 | } 18 | } 19 | 20 | function createClient() { 21 | const faunaClient = new Client({ 22 | secret: process.env[ENV_NAME], 23 | }); 24 | console.log(chalk.gray('\n🛠 ') + ` Fauna client created`); 25 | return faunaClient; 26 | } 27 | 28 | // Helpers 29 | const createThen = (typeName) => (r) => { 30 | console.log(chalk.green('✅') + ` Created ${typeName}`); 31 | return r; 32 | }; 33 | 34 | const createCatch = (typeName) => (e) => { 35 | try { 36 | // console.log(e); 37 | if (e.message === 'instance already exists') { 38 | console.log( 39 | chalk.yellow('⏭ ') + ` ${typeName} already exists. Skipping...` 40 | ); 41 | } else if (e.description === 'Unauthorized') { 42 | e.description = 43 | 'Unauthorized: missing or invalid FAUNADB_ADMIN_SECRET, or not enough permissions'; 44 | throw e; 45 | } else if ( 46 | e.description === 'Insufficient privileges to perform the action.' 47 | ) { 48 | e.description = 49 | 'Insufficient privileges to perform the action. Check you are using an admin key instead of a server one'; 50 | throw e; 51 | } else if (e.description === 'document is not unique.') { 52 | e.description = `${typeName} already exists`; 53 | throw e; 54 | } else { 55 | throw e; 56 | } 57 | } catch (e) { 58 | console.log(chalk.green('⛔️ ') + (e.description || e.message)); 59 | } 60 | }; 61 | 62 | const updateThen = (typeName) => (r) => { 63 | console.log(chalk.blue('✅ ') + `Updated ${typeName}`); 64 | return r; 65 | }; 66 | 67 | const updateCatch = (e) => { 68 | if (e) { 69 | if (e.message === 'unauthorized') { 70 | e.message = 71 | 'unauthorized: missing or invalid fauna_server_secret, or not enough permissions'; 72 | throw e; 73 | } else { 74 | throw e; 75 | } 76 | } 77 | }; 78 | 79 | module.exports = { 80 | ENV_NAME: ENV_NAME, 81 | faunaClient: validateThenRun(createClient), 82 | createClient: createClient, 83 | validateThenRun: validateThenRun, 84 | updateCatch: updateCatch, 85 | updateThen: updateThen, 86 | createCatch: createCatch, 87 | createThen: createThen, 88 | }; 89 | -------------------------------------------------------------------------------- /scripts/createDoc.js: -------------------------------------------------------------------------------- 1 | const { query: q } = require('faunadb'); 2 | const chalk = require('chalk'); 3 | 4 | const { faunaClient, createThen, createCatch } = require('./config'); 5 | 6 | // The following is an example of how to create the first user in the db 7 | // directly with the javascript driver 8 | 9 | const createUser = async () => { 10 | console.log(chalk.yellow('\n⚡️ ') + chalk.cyan('Creating user\n')); 11 | return faunaClient 12 | .query( 13 | q.Create(q.Collection('User'), { 14 | data: { 15 | email: '123@example.com', 16 | role: 'FREE_USER', 17 | }, 18 | credentials: { 19 | password: '123', 20 | }, 21 | }) 22 | ) 23 | .then(createThen('Document "User"')) 24 | .catch(createCatch('Document "User"')); 25 | }; 26 | 27 | module.exports.createUser = createUser; 28 | -------------------------------------------------------------------------------- /scripts/createRoles.js: -------------------------------------------------------------------------------- 1 | const { query: q } = require('faunadb'); 2 | const chalk = require('chalk'); 3 | 4 | const { faunaClient, createThen, createCatch } = require('./config'); 5 | 6 | // This file contains the config for all the roles 7 | // Notice we are also creating here a function (crFnc1) that uses a previously created role (crRol5) 8 | 9 | const crRol0 = () => { 10 | return console.log(chalk.yellow('\n⚡️ ') + chalk.cyan('Creating roles\n')); 11 | }; 12 | 13 | const crRol1 = async () => { 14 | return faunaClient 15 | .query( 16 | q.CreateRole({ 17 | name: 'fnc_role_create_user', 18 | privileges: [ 19 | { 20 | resource: q.Collection('User'), 21 | actions: { 22 | read: true, 23 | create: q.Query( 24 | q.Lambda( 25 | 'values', 26 | q.Equals( 27 | q.Select(['data', 'role'], q.Var('values')), 28 | 'FREE_USER' 29 | ) 30 | ) 31 | ), 32 | }, 33 | }, 34 | ], 35 | }) 36 | ) 37 | .then(createThen(`Role "fnc_role_create_user"`)) 38 | .catch(createCatch(`Role "fnc_role_create_user"`)); 39 | }; 40 | 41 | const crRol2 = async () => { 42 | return faunaClient 43 | .query( 44 | q.CreateRole({ 45 | name: 'fnc_role_login_user', 46 | privileges: [ 47 | { 48 | resource: q.Index('unique_User_email'), 49 | actions: { 50 | unrestricted_read: true, 51 | }, 52 | }, 53 | { 54 | resource: q.Collection('User'), 55 | actions: { 56 | read: true, 57 | }, 58 | }, 59 | ], 60 | }) 61 | ) 62 | .then(createThen(`Role "fnc_role_login_user"`)) 63 | .catch(createCatch(`Role "fnc_role_login_user"`)); 64 | }; 65 | 66 | const crRol3 = async () => { 67 | return faunaClient 68 | .query( 69 | q.CreateRole({ 70 | name: 'fnc_role_logout_user', 71 | privileges: [ 72 | { 73 | resource: q.Ref('tokens'), 74 | actions: { 75 | create: true, 76 | read: true, 77 | }, 78 | }, 79 | ], 80 | }) 81 | ) 82 | .then(createThen(`Role "fnc_role_logout_user"`)) 83 | .catch(createCatch(`Role "fnc_role_logout_user"`)); 84 | }; 85 | 86 | const crRol4 = async () => { 87 | return faunaClient 88 | .query( 89 | q.CreateRole({ 90 | name: 'fnc_role_signup_user', 91 | privileges: [ 92 | { 93 | resource: q.Function('create_user'), 94 | actions: { 95 | call: true, 96 | }, 97 | }, 98 | { 99 | resource: q.Function('login_user'), 100 | actions: { 101 | call: true, 102 | }, 103 | }, 104 | ], 105 | }) 106 | ) 107 | .then(createThen(`Role "fnc_role_signup_user"`)) 108 | .catch(createCatch(`Role "fnc_role_signup_user"`)); 109 | }; 110 | 111 | const crRol5 = async () => { 112 | return faunaClient 113 | .query( 114 | q.CreateRole({ 115 | name: 'fnc_role_validate_token', 116 | privileges: [ 117 | { 118 | resource: q.Ref('tokens'), 119 | actions: { 120 | read: true, 121 | }, 122 | }, 123 | ], 124 | }) 125 | ) 126 | .then(createThen(`Role "fnc_role_validate_token"`)) 127 | .catch(createCatch(`Role "fnc_role_validate_token"`)); 128 | }; 129 | 130 | const crFnc1 = async () => { 131 | return faunaClient 132 | .query( 133 | q.CreateFunction({ 134 | name: 'validate_token', 135 | body: q.Query( 136 | q.Lambda( 137 | '_', 138 | q.Abort('Function validate_token is not implemented yet.') 139 | ) 140 | ), 141 | }) 142 | ) 143 | .then(createThen(`Function "validate_token"`)) 144 | .catch(createCatch(`Function "validate_token"`)); 145 | }; 146 | 147 | const crRol6 = async () => { 148 | return faunaClient 149 | .query( 150 | q.CreateRole({ 151 | name: 'free_user', 152 | privileges: [ 153 | { 154 | resource: q.Collection('User'), 155 | actions: { 156 | read: q.Query( 157 | q.Lambda('userRef', q.Equals(q.Identity(), q.Var('userRef'))) 158 | ), 159 | write: q.Query( 160 | q.Lambda( 161 | ['_', 'newData', 'userRef'], 162 | q.And( 163 | q.Equals(q.Identity(), q.Var('userRef')), 164 | q.Equals( 165 | 'FREE_USER', 166 | q.Select(['data', 'role'], q.Var('newData')) 167 | ) 168 | ) 169 | ) 170 | ), 171 | }, 172 | }, 173 | { 174 | // This is restricting the actions the User can do with Thing 175 | // Basically, making sure each user can only see, create, modify or delete his or her stuff 176 | resource: q.Collection('Thing'), 177 | actions: { 178 | read: q.Query( 179 | q.Lambda( 180 | 'ref', 181 | q.Equals( 182 | q.Identity(), 183 | q.Select(['data', 'owner'], q.Get(q.Var('ref'))) 184 | ) 185 | ) 186 | ), 187 | write: q.Query( 188 | q.Lambda( 189 | ['oldData', 'newData'], 190 | q.And( 191 | q.Equals( 192 | q.Identity(), 193 | q.Select(['data', 'owner'], q.Var('oldData')) 194 | ), 195 | q.Equals( 196 | q.Select(['data', 'owner'], q.Var('oldData')), 197 | q.Select(['data', 'owner'], q.Var('newData')) 198 | ) 199 | ) 200 | ) 201 | ), 202 | create: q.Query( 203 | q.Lambda( 204 | 'values', 205 | q.Equals( 206 | q.Identity(), 207 | q.Select(['data', 'owner'], q.Var('values')) 208 | ) 209 | ) 210 | ), 211 | delete: q.Query( 212 | q.Lambda( 213 | 'ref', 214 | q.Equals( 215 | q.Identity(), 216 | q.Select(['data', 'owner'], q.Get(q.Var('ref'))) 217 | ) 218 | ) 219 | ), 220 | }, 221 | }, 222 | { 223 | resource: q.Function('validate_token'), 224 | actions: { 225 | call: true, 226 | }, 227 | }, 228 | { 229 | resource: q.Function('logout_user'), 230 | actions: { 231 | call: true, 232 | }, 233 | }, 234 | { 235 | resource: q.Index('findAllThings'), 236 | actions: { 237 | unrestricted_read: false, 238 | read: true, 239 | }, 240 | }, 241 | ], 242 | membership: [ 243 | { 244 | resource: q.Collection('User'), 245 | predicate: q.Query( 246 | q.Lambda( 247 | 'userRef', 248 | q.Or( 249 | q.Equals( 250 | q.Select(['data', 'role'], q.Get(q.Var('userRef'))), 251 | 'FREE_USER' 252 | ) 253 | ) 254 | ) 255 | ), 256 | }, 257 | ], 258 | }) 259 | ) 260 | .then(createThen(`Role "free_user"`)) 261 | .catch(createCatch(`Role "free_user"`)); 262 | }; 263 | 264 | const crRol7 = async () => { 265 | return faunaClient 266 | .query( 267 | q.CreateRole({ 268 | name: 'public', 269 | privileges: [ 270 | { 271 | resource: q.Function('signup_user'), 272 | actions: { 273 | call: true, 274 | }, 275 | }, 276 | { 277 | resource: q.Function('login_user'), 278 | actions: { 279 | call: true, 280 | }, 281 | }, 282 | ], 283 | }) 284 | ) 285 | .then(createThen(`Role "public"`)) 286 | .catch(createCatch(`Role "public"`)); 287 | }; 288 | 289 | const fnList = [ 290 | crRol0, 291 | crRol1, 292 | crRol2, 293 | crRol3, 294 | crRol4, 295 | crRol5, 296 | crFnc1, 297 | crRol6, 298 | crRol7, 299 | ]; 300 | 301 | module.exports.roleFnList = fnList; 302 | -------------------------------------------------------------------------------- /scripts/faunaSchema.graphql: -------------------------------------------------------------------------------- 1 | enum UserRole { 2 | FREE_USER 3 | } 4 | 5 | # If creating one-to-one relationships, take this into account 6 | # https://forums.fauna.com/t/in-a-one-to-one-relation-between-two-collections-provide-a-mechanism-to-control-in-what-collection-the-reference-will-be-stored/36 7 | # In this example we'll be using a one-to-many relationship 8 | # https://docs.fauna.com/fauna/current/api/graphql/relations#one2many 9 | # A user can own many things, and a thing can be owned by one user (for the example's sake) 10 | 11 | type User { 12 | things: [Thing] @relation 13 | email: String! @unique 14 | role: UserRole! 15 | } 16 | 17 | type Thing { 18 | owner: User! 19 | name: String 20 | } 21 | 22 | input CreateUserInput { 23 | email: String! 24 | password: String! 25 | role: UserRole! 26 | } 27 | 28 | input LoginUserInput { 29 | email: String! 30 | password: String! 31 | } 32 | 33 | type LoginRes @embedded { 34 | userToken: String 35 | userId: String! 36 | } 37 | 38 | type Query { 39 | findAllThings: [Thing] 40 | } 41 | 42 | type Mutation { 43 | signupUser(data: CreateUserInput!): LoginRes! @resolver(name: "signup_user") 44 | createUser(data: CreateUserInput!): User! @resolver(name: "create_user") 45 | loginUser(data: LoginUserInput!): LoginRes! @resolver(name: "login_user") 46 | logoutUser: Boolean! @resolver(name: "logout_user") 47 | } 48 | -------------------------------------------------------------------------------- /scripts/manageKeys.js: -------------------------------------------------------------------------------- 1 | const { query: q } = require('faunadb'); 2 | const chalk = require('chalk'); 3 | 4 | const { faunaClient, createThen, createCatch } = require('./config'); 5 | 6 | async function manageKeys() { 7 | console.log(chalk.yellow('\n⚡️ ') + chalk.cyan('Manage keys\n')); 8 | const publicKey = ( 9 | await faunaClient 10 | .query( 11 | q.CreateKey({ 12 | name: `public_key`, 13 | role: q.Role('public'), 14 | }) 15 | ) 16 | .then(createThen(`Key "public_key"`)) 17 | .catch(createCatch(`Key "public_key"`)) 18 | ).secret; 19 | console.log( 20 | chalk.yellow('\n🚀 ') + 21 | 'Save this public client key in the .env file:\n\n' + 22 | ' FAUNADB_PUBLIC_ACCESS_KEY=' + 23 | chalk.yellow(publicKey) 24 | ); 25 | } 26 | 27 | module.exports.manageKeys = manageKeys; 28 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | const { uploadSchema } = require('./uploadSchema'); 2 | const { fnList } = require('./updateFunctions'); 3 | const { roleFnList } = require('./createRoles'); 4 | const { manageKeys } = require('./manageKeys'); 5 | const { createUser } = require('./createDoc'); 6 | 7 | const fullFnList = [ 8 | uploadSchema, 9 | ...roleFnList, 10 | ...fnList, 11 | createUser, 12 | manageKeys, 13 | ]; 14 | 15 | // Inspired by the script on ptpaterson's example 16 | // https://github.com/ptpaterson/netlify-faunadb-graphql-auth/tree/master/scripts 17 | 18 | module.exports.full = async function full() { 19 | for (const fn of fullFnList) { 20 | await fn(); 21 | } 22 | }; 23 | 24 | module.exports.updateFunctions = async function updateFunctions() { 25 | for (const fn of fnList) { 26 | await fn(); 27 | } 28 | }; 29 | 30 | module.exports.createRoles = async function createRoles() { 31 | for (const fn of roleFnList) { 32 | await fn(); 33 | } 34 | }; 35 | 36 | module.exports.manageKeys = manageKeys; 37 | 38 | module.exports.uploadSchema = uploadSchema; 39 | 40 | module.exports.createUser = createUser; 41 | 42 | // Run: 43 | // node -e 'require("./scripts/setup.js").full()' 44 | // to run the full setup script 45 | // Alternatevely you can run each function by it self 46 | // Similar to: 47 | // node -e 'require("./scripts/setup.js").updateFunctions()' 48 | // or 49 | // node -e 'require("./scripts/setup.js").uploadSchema()' 50 | -------------------------------------------------------------------------------- /scripts/updateFunctions.js: -------------------------------------------------------------------------------- 1 | const { query: q } = require('faunadb'); 2 | const chalk = require('chalk'); 3 | 4 | const { faunaClient, updateThen, updateCatch } = require('./config'); 5 | 6 | const upFn0 = () => { 7 | return console.log( 8 | chalk.yellow('\n⚡️ ') + chalk.cyan('Updating functions\n') 9 | ); 10 | }; 11 | 12 | const upFn1 = async () => { 13 | return faunaClient 14 | .query( 15 | q.Update(q.Function('create_user'), { 16 | role: q.Role('fnc_role_create_user'), 17 | body: q.Query( 18 | q.Lambda( 19 | ['input'], 20 | q.Create(q.Collection('User'), { 21 | data: { 22 | email: q.Select('email', q.Var('input')), 23 | role: q.Select('role', q.Var('input')), 24 | }, 25 | credentials: { password: q.Select('password', q.Var('input')) }, 26 | }) 27 | ) 28 | ), 29 | }) 30 | ) 31 | .then(updateThen('Function "create_user"')) 32 | .catch(updateCatch); 33 | }; 34 | 35 | const upFn2 = async () => { 36 | return faunaClient 37 | .query( 38 | q.Update(q.Function('login_user'), { 39 | role: q.Role('fnc_role_login_user'), 40 | body: q.Query( 41 | q.Lambda(['input'], { 42 | userToken: q.Select( 43 | ['secret'], 44 | q.Login( 45 | q.Select( 46 | ['ref'], 47 | q.Get( 48 | q.Match( 49 | q.Index('unique_User_email'), 50 | q.Select('email', q.Var('input')) 51 | ) 52 | ) 53 | ), 54 | { 55 | password: q.Select('password', q.Var('input')), 56 | ttl: q.TimeAdd(q.Now(), 14, 'days'), 57 | } 58 | ) 59 | ), 60 | userId: q.Select( 61 | ['ref', 'id'], 62 | q.Get( 63 | q.Match( 64 | q.Index('unique_User_email'), 65 | q.Select('email', q.Var('input')) 66 | ) 67 | ) 68 | ), 69 | }) 70 | ), 71 | }) 72 | ) 73 | .then(updateThen('Function "login_user"')) 74 | .catch(updateCatch); 75 | }; 76 | 77 | const upFn3 = async () => { 78 | return faunaClient 79 | .query( 80 | q.Update(q.Function('logout_user'), { 81 | role: q.Role('fnc_role_logout_user'), 82 | body: q.Query(q.Lambda('_', q.Logout(true))), 83 | }) 84 | ) 85 | .then(updateThen('Function "logout_user"')) 86 | .catch(updateCatch); 87 | }; 88 | 89 | const upFn4 = async () => { 90 | return faunaClient 91 | .query( 92 | q.Update(q.Function('signup_user'), { 93 | role: q.Role('fnc_role_signup_user'), 94 | body: q.Query( 95 | q.Lambda( 96 | ['input'], 97 | q.Do( 98 | q.Call(q.Function('create_user'), q.Var('input')), 99 | q.Call(q.Function('login_user'), q.Var('input')) 100 | ) 101 | ) 102 | ), 103 | }) 104 | ) 105 | .then(updateThen('Function "signup_user"')) 106 | .catch(updateCatch); 107 | }; 108 | 109 | const upFn5 = async () => { 110 | return faunaClient 111 | .query( 112 | q.Update(q.Function('validate_token'), { 113 | role: q.Role('fnc_role_validate_token'), 114 | body: q.Query( 115 | q.Lambda(['token'], q.Not(q.IsNull(q.KeyFromSecret(q.Var('token'))))) 116 | ), 117 | }) 118 | ) 119 | .then(updateThen('Function "validate_token"')) 120 | .catch(updateCatch); 121 | }; 122 | 123 | const fnList = [upFn0, upFn1, upFn2, upFn3, upFn4, upFn5]; 124 | 125 | module.exports.fnList = fnList; 126 | -------------------------------------------------------------------------------- /scripts/uploadSchema.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.env.local' }); 2 | const request = require('request'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const chalk = require('chalk'); 6 | 7 | const { ENV_NAME } = require('./config'); 8 | 9 | async function uploadSchema() { 10 | await new Promise((resolve, reject) => { 11 | console.log( 12 | chalk.yellow('\n⚡️ ') + chalk.cyan('Uploading Graphql Schema...\n') 13 | ); 14 | fs.createReadStream(path.join(__dirname, 'faunaSchema.graphql')).pipe( 15 | request.post( 16 | { 17 | type: 'application/octet-stream', 18 | headers: { 19 | Authorization: `Bearer ${process.env[ENV_NAME]}`, 20 | }, 21 | url: 'https://graphql.fauna.com/import', 22 | }, 23 | (err, res, body) => { 24 | if (err) reject(err); 25 | resolve(body); 26 | } 27 | ) 28 | ); 29 | }) 30 | .then(() => console.log(chalk.blue('✅ ') + `GraphQL schema imported`)) 31 | .catch((error) => { 32 | console.log(chalk.red('⛔️ ') + ` Error during schema import`); 33 | console.log(error); 34 | process.exit(1); 35 | }); 36 | } 37 | 38 | module.exports.uploadSchema = uploadSchema; 39 | --------------------------------------------------------------------------------