├── client ├── .npmrc ├── src │ ├── react-app-env.d.ts │ ├── index.tsx │ ├── App.css │ ├── index.css │ ├── components │ │ ├── UserContext.tsx │ │ ├── LinkLauncher.tsx │ │ ├── DebugPanel.tsx │ │ ├── LinkLoader.tsx │ │ ├── UserStatus.tsx │ │ ├── BankIncome.tsx │ │ ├── PayrollIncome.tsx │ │ └── Liabilities.tsx │ ├── App.tsx │ └── logo.svg ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── tsconfig.json └── package.json ├── server ├── .npmrc ├── user_data.json.template ├── package.json ├── .env.template ├── server.js └── package-lock.json ├── LICENSE ├── .gitignore └── README.md /client/.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /server/.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaid/income-sample/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /server/user_data.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": null, 3 | "incomeConnected": false, 4 | "userId": null, 5 | "incomeUserToken": null 6 | } 7 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") as HTMLElement 11 | ); 12 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Income Sample", 3 | "name": "Plaid Income Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .Subtitle { 6 | font-size: small; 7 | } 8 | 9 | .App-header { 10 | background-color: #282c34; 11 | min-height: 100vh; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | font-size: calc(1px + 2vmin); 17 | color: white; 18 | } 19 | 20 | .App-link { 21 | color: #61dafb; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/components/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export enum PlaidConnectStatus { 4 | Unknown = "unknown", 5 | Connected = "connected", 6 | NotConnected = "notConnected", 7 | } 8 | 9 | export const UserContext = createContext({ 10 | user: { 11 | userName: "", 12 | incomeConnected: PlaidConnectStatus.Unknown, 13 | incomeUpdateTime: Date.now(), 14 | liabilitiesConnected: PlaidConnectStatus.Unknown, 15 | }, 16 | setUser: (obj: any) => {}, 17 | }); 18 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "Our application server", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js", 9 | "watch": "nodemon server.js --watch server.js" 10 | }, 11 | "author": "Todd Kerpelman", 12 | "license": "MIT", 13 | "dependencies": { 14 | "body-parser": "^1.20.3", 15 | "dotenv": "^16.6.1", 16 | "express": "^4.21.2", 17 | "nodemon": "^3.1.10", 18 | "plaid": "^39.1.0", 19 | "uuid": "^9.0.1" 20 | }, 21 | "engines": { 22 | "node": ">=14.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /server/.env.template: -------------------------------------------------------------------------------- 1 | # Copy to .env and add in real key 2 | # Get your Plaid API keys from the dashboard: https://dashboard.plaid.com/account/keys 3 | PLAID_CLIENT_ID= 4 | PLAID_SECRET= 5 | 6 | # Use 'sandbox' to test with fake credentials in Plaid's Sandbox environment 7 | # Use 'production' to to use real data 8 | # When you switch environments, you'll need to re-write user_data.json with 9 | # user_data_orig.json, or your application won't work. 10 | PLAID_ENV=sandbox 11 | 12 | # If you want to see incoming webhooks -- particularly relevant when 13 | # performing the Documentation Income flow -- add your webhook URL here. 14 | # In development, consider using ngrok to redirect http requests to port 8001. 15 | # See the readme for more information. 16 | WEBHOOK_URL=https://123-456-789-abc.ngrok.com/server/receive_webhook 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Plaid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ChakraProvider, Text } from "@chakra-ui/react"; 3 | import { Box, Flex, Heading, VStack } from "@chakra-ui/layout"; 4 | import { UserContext, PlaidConnectStatus } from "./components/UserContext"; 5 | import UserStatus from "./components/UserStatus"; 6 | import DebugPanel from "./components/DebugPanel"; 7 | 8 | function App() { 9 | const [user, setUser] = useState({ 10 | userName: "Default User", 11 | incomeConnected: PlaidConnectStatus.Unknown, 12 | incomeUpdateTime: Date.now(), 13 | liabilitiesConnected: PlaidConnectStatus.Unknown, 14 | }); 15 | 16 | return ( 17 | 18 | 24 | Todd's Pre-owned hoverboards! 25 | 26 | If they set your house on fire, your next hoverboard is half off! 27 | 28 | 36 | 37 | Financing 38 | 39 | Find out if you qualify for financing for a pre-owned hoverboard! 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | 55 | export default App; 56 | -------------------------------------------------------------------------------- /client/src/components/LinkLauncher.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { 3 | usePlaidLink, 4 | PlaidLinkOnSuccessMetadata, 5 | PlaidLinkOnExitMetadata, 6 | PlaidLinkError, 7 | PlaidLinkOptionsWithLinkToken, 8 | PlaidLinkOnEventMetadata, 9 | PlaidLinkStableEvent, 10 | } from "react-plaid-link"; 11 | 12 | interface Props { 13 | isIncome?: boolean; 14 | token: string; 15 | successCallback: (_: string) => Promise; 16 | } 17 | 18 | /** 19 | * Launches Link, calling props.successCallback when complete. This could have 20 | * been combined with the LinkLoader, but it got a bit unweildy 21 | */ 22 | export default function LaunchLink(props: Props) { 23 | // define onSuccess, onExit and onEvent functions as configs for Plaid Link creation 24 | const onSuccess = async ( 25 | publicToken: string, 26 | metadata: PlaidLinkOnSuccessMetadata 27 | ) => { 28 | console.log(`Hooray! Public token is ${publicToken}`); 29 | console.log(metadata); 30 | props.successCallback(publicToken); 31 | }; 32 | 33 | const onExit = async ( 34 | error: PlaidLinkError | null, 35 | metadata: PlaidLinkOnExitMetadata 36 | ) => { 37 | console.log(`Awww...${JSON.stringify(error)}`); 38 | console.log(metadata); 39 | }; 40 | 41 | const onEvent = async ( 42 | eventName: PlaidLinkStableEvent | string, 43 | metadata: PlaidLinkOnEventMetadata 44 | ) => { 45 | console.log(`Event: ${eventName}, Metadata: ${metadata}`); 46 | }; 47 | 48 | const config: PlaidLinkOptionsWithLinkToken = { 49 | onSuccess, 50 | onExit, 51 | onEvent, 52 | token: props.token, 53 | }; 54 | 55 | const { open, ready } = usePlaidLink(config); 56 | 57 | useEffect(() => { 58 | if (ready) { 59 | open(); 60 | } 61 | }, [ready, open, props.token]); 62 | 63 | return <>; 64 | } 65 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | Todd's Pre-owned Hoverboards 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "incomesample", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/layout": "^1.8.0", 7 | "@chakra-ui/react": "^1.8.8", 8 | "@chakra-ui/system": "^1.12.1", 9 | "@types/jest": "^30.0.0", 10 | "@types/node": "^24.10.0", 11 | "@types/react": "^18.3.26", 12 | "@types/react-dom": "^18.3.7", 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "react-plaid-link": "^4.1.1", 16 | "react-scripts": "5.0.1", 17 | "typescript": "^4.9.5", 18 | "web-vitals": "^5.1.0" 19 | }, 20 | "proxy": "http://localhost:8080", 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "overrides": { 45 | "svgo": { 46 | "nth-check": ">=2.0.2" 47 | }, 48 | "react-scripts": { 49 | "postcss": ">=8.4.31" 50 | }, 51 | "react-focus-lock": { 52 | "react": "$react", 53 | "react-dom": "$react-dom" 54 | }, 55 | "react-remove-scroll": { 56 | "react": "$react", 57 | "react-dom": "$react-dom" 58 | }, 59 | "@reach/alert": { 60 | "react": "$react", 61 | "react-dom": "$react-dom" 62 | }, 63 | "@reach/utils": { 64 | "react": "$react", 65 | "react-dom": "$react-dom" 66 | }, 67 | "@reach/visually-hidden": { 68 | "react": "$react", 69 | "react-dom": "$react-dom" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Env file that might contain actual secrets 3 | server/.env 4 | 5 | # User_data that might contain actual access tokens 6 | server/user_data.json 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variables file 79 | .env 80 | .env.test 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | 85 | # Next.js build output 86 | .next 87 | 88 | # Nuxt.js build / generate output 89 | .nuxt 90 | dist 91 | 92 | # Gatsby files 93 | .cache/ 94 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 95 | # https://nextjs.org/blog/next-9-1#public-directory-support 96 | # public 97 | 98 | # vuepress build output 99 | .vuepress/dist 100 | 101 | # Serverless directories 102 | .serverless/ 103 | 104 | # FuseBox cache 105 | .fusebox/ 106 | 107 | # DynamoDB Local files 108 | .dynamodb/ 109 | 110 | # TernJS port file 111 | .tern-port 112 | 113 | # VS-code specific things 114 | .vscode 115 | 116 | # A folder I use to keep track of .env and user_data files with actual data in them 117 | server/.devValues -------------------------------------------------------------------------------- /client/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/DebugPanel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionItem, 4 | AccordionButton, 5 | Box, 6 | AccordionPanel, 7 | AccordionIcon, 8 | Button, 9 | Input, 10 | Flex, 11 | Spacer, 12 | } from "@chakra-ui/react"; 13 | import { useState } from "react"; 14 | 15 | const DebugPanel = () => { 16 | const [webhookURL, setWebhookURL] = useState(""); 17 | 18 | const performPrecheck = async (targetConfidence: string) => { 19 | const precheckResponse = await fetch("/appServer/simulate_precheck", { 20 | method: "POST", 21 | headers: { "Content-type": "application/json" }, 22 | body: JSON.stringify({ confidence: targetConfidence }), 23 | }); 24 | const precheckData = await precheckResponse.json(); 25 | console.log( 26 | `I got back this precheck data: ${JSON.stringify(precheckData)}` 27 | ); 28 | if (precheckData.confidence === "HIGH") { 29 | console.log( 30 | "From now on, payroll income will default to using Zenefits." 31 | ); 32 | } else if (precheckData.confidence === "UNKNOWN") { 33 | console.log("Payroll income will go back to an unknown state"); 34 | } 35 | }; 36 | 37 | const updateWebhook = async () => { 38 | const webhookResponse = await fetch("/server/update_webhook", { 39 | method: "POST", 40 | headers: { "Content-type": "application/json" }, 41 | body: JSON.stringify({ newUrl: webhookURL }), 42 | }); 43 | const webhookResponseData = await webhookResponse.json(); 44 | console.log( 45 | `Response from updating webhook: ${JSON.stringify(webhookResponseData)}` 46 | ); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | 60 | 66 | 67 | 68 | 69 | setWebhookURL(e.target.value)} 72 | value={webhookURL} 73 | /> 74 | 75 | 82 | 83 | 84 | 85 |

86 | 87 | 88 | Debug items 89 | 90 | 91 | 92 |

93 |
94 |
95 | ); 96 | }; 97 | 98 | export default DebugPanel; 99 | -------------------------------------------------------------------------------- /client/src/components/LinkLoader.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | import { UserContext, PlaidConnectStatus } from "./UserContext"; 3 | import LaunchLink from "./LinkLauncher"; 4 | import { Button } from "@chakra-ui/button"; 5 | 6 | export enum IncomeType { 7 | Bank = "bank", 8 | Payroll = "payroll", 9 | } 10 | 11 | interface Props { 12 | income: boolean; 13 | incomeType: IncomeType; 14 | buttonText: string; 15 | } 16 | 17 | /** 18 | * Grabs a link token from the server, calls Link, and then either sends the 19 | * public token back down to the server, or just reports success back ot the 20 | * sever. The behavior of this changes quite a bit depending on whether 21 | * or not you're using Plaid Income. 22 | */ 23 | 24 | const LinkLoader = (props: Props) => { 25 | const [linkToken, setLinkToken] = useState(""); 26 | const { user, setUser } = useContext(UserContext); 27 | 28 | const loadAndLaunchLink = async () => { 29 | const linkToken = await fetchLinkToken(); 30 | setLinkToken(linkToken); 31 | }; 32 | 33 | const linkSuccess = async (public_token: String) => { 34 | if (public_token != null && public_token !== "") { 35 | if (props.income) { 36 | await incomeSuccess(public_token); 37 | } else { 38 | await accessTokenSuccess(public_token); 39 | } 40 | } 41 | }; 42 | 43 | const incomeSuccess = async (public_token: String) => { 44 | const response = await fetch("/appServer/income_was_successful", { 45 | method: "POST", 46 | headers: { "Content-type": "application/json" }, 47 | }); 48 | console.log(response); 49 | setUser( 50 | Object.assign({}, user, { 51 | incomeConnected: PlaidConnectStatus.Connected, 52 | incomeUpdateTime: Date.now(), 53 | }) 54 | ); 55 | }; 56 | 57 | const accessTokenSuccess = async (public_token: String) => { 58 | const response = await fetch("/appServer/swap_public_token", { 59 | method: "POST", 60 | headers: { "Content-type": "application/json" }, 61 | body: JSON.stringify({ 62 | public_token: public_token, 63 | }), 64 | }); 65 | console.log(response); 66 | setUser( 67 | Object.assign({}, user, { 68 | liabilitiesConnected: PlaidConnectStatus.Connected, 69 | }) 70 | ); 71 | }; 72 | 73 | const fetchLinkToken = async () => { 74 | const messageBody = props.income 75 | ? JSON.stringify({ 76 | income: true, 77 | incomeType: props.incomeType, 78 | }) 79 | : JSON.stringify({ 80 | income: false, 81 | }); 82 | 83 | const response = await fetch("/appServer/generate_link_token", { 84 | method: "POST", 85 | headers: { "Content-type": "application/json" }, 86 | body: messageBody, 87 | }); 88 | if (response.status === 500) { 89 | alert( 90 | "We received an error trying to create a link token. Please make sure you've followed all the setup steps in the readme file, and that your account is activated for income verification." 91 | ); 92 | } else { 93 | const data = await response.json(); 94 | 95 | console.log(`Got back link token ${data.link_token}`); 96 | return data.link_token; 97 | } 98 | }; 99 | 100 | return ( 101 | <> 102 | 105 | 106 | 107 | ); 108 | }; 109 | 110 | LinkLoader.defaultProps = { 111 | income: false, 112 | incomeType: IncomeType.Payroll, 113 | buttonText: "Connect my bank", 114 | }; 115 | 116 | export default LinkLoader; 117 | -------------------------------------------------------------------------------- /client/src/components/UserStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect } from "react"; 2 | import BankIncome from "./BankIncome"; 3 | import PayrollIncome from "./PayrollIncome"; 4 | import Liabilities from "./Liabilities"; 5 | import LinkLoader, { IncomeType } from "./LinkLoader"; 6 | import { UserContext, PlaidConnectStatus } from "./UserContext"; 7 | import { Flex, Heading, Spacer, VStack } from "@chakra-ui/layout"; 8 | import { Text } from "@chakra-ui/react"; 9 | 10 | /** 11 | * This object queries the server to find out if the user has connected their 12 | * bank or payroll provider with Plaid for either the Liabilities or Income 13 | * product and then displays either the proper component or a "Connect your 14 | * bank" kind of button 15 | */ 16 | const UserStatus = () => { 17 | const { user, setUser } = useContext(UserContext); 18 | 19 | const getInfo = useCallback(async () => { 20 | const url = "/appServer/get_user_info"; 21 | const response = await fetch(url); 22 | const data = await response.json(); 23 | console.log(`Here's your user data`); 24 | console.log(data); 25 | const liabilityStatus = data["liability_status"] 26 | ? PlaidConnectStatus.Connected 27 | : PlaidConnectStatus.NotConnected; 28 | const incomeStatus = data["income_status"] 29 | ? PlaidConnectStatus.Connected 30 | : PlaidConnectStatus.NotConnected; 31 | 32 | setUser( 33 | Object.assign({}, user, { 34 | incomeConnected: incomeStatus, 35 | liabilitiesConnected: liabilityStatus, 36 | }) 37 | ); 38 | }, [setUser, user]); 39 | 40 | useEffect(() => { 41 | console.log(`Here's your user ${JSON.stringify(user)}`); 42 | if ( 43 | user.incomeConnected === PlaidConnectStatus.Unknown || 44 | user.liabilitiesConnected === PlaidConnectStatus.Unknown 45 | ) { 46 | getInfo(); 47 | } 48 | }, [user, getInfo]); 49 | 50 | return ( 51 | 52 | {user.liabilitiesConnected === PlaidConnectStatus.Unknown ? ( 53 | Getting connection status 54 | ) : user.liabilitiesConnected === PlaidConnectStatus.Connected ? ( 55 | <> 56 | 57 | 58 | ) : ( 59 | <> 60 | 61 | Outstanding loans 62 | 63 | Tell us a little about your current loans 64 | 68 | 69 | )} 70 | {user.incomeConnected === PlaidConnectStatus.Unknown ? ( 71 | Getting income status 72 | ) : user.incomeConnected === PlaidConnectStatus.Connected ? ( 73 | 74 | 75 | 76 | 77 | 78 | ) : ( 79 | <> 80 | 81 | Sources of Income 82 | 83 |

Tell us about your sources of income!

84 | 85 | 90 | 95 | 96 | 97 | )} 98 |
99 | ); 100 | }; 101 | 102 | export default UserStatus; 103 | -------------------------------------------------------------------------------- /client/src/components/BankIncome.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useState } from "react"; 2 | import { UserContext } from "./UserContext"; 3 | import LinkLoader, { IncomeType } from "./LinkLoader"; 4 | import { Box, Flex, Heading, VStack } from "@chakra-ui/layout"; 5 | 6 | interface BankData { 7 | bank_name: string; 8 | total_amount: number; 9 | transaction_count: number; 10 | description: number; 11 | income_id: string; 12 | } 13 | 14 | /** 15 | * Retrieves any income data form the user's bank and displays it in a 16 | * somewhat user-friendly format 17 | */ 18 | const BankIncome = () => { 19 | const [bankIncome, setBankIncome] = useState(Array()); 20 | const hardCodedCurrencyCode = "USD"; 21 | const { user } = useContext(UserContext); 22 | 23 | const getIncome = useCallback(async () => { 24 | const response = await fetch("/appServer/get_bank_income"); 25 | const data = await response.json(); 26 | console.log("Bank Income: ", data); 27 | 28 | type BankItemType = { 29 | institution_name: string; 30 | bank_income_sources: { 31 | total_amount: number; 32 | transaction_count: number; 33 | income_description: number; 34 | income_source_id: string; 35 | }[]; 36 | }; 37 | 38 | type BankIncomeType = { 39 | items: BankItemType[]; 40 | }; 41 | 42 | // `bank_income` is an array of objects, each of which contains an array of 43 | // `items`, which are objects that, in turn, contains an array of 44 | // `bank_income_sources`. 45 | const thisUsersIncome: Array = 46 | data.bank_income?.flatMap((report: BankIncomeType) => { 47 | return report.items.flatMap((item) => { 48 | const institution_name = item.institution_name; 49 | const income_sources: Array = item.bank_income_sources.map( 50 | (source) => ({ 51 | bank_name: institution_name, 52 | total_amount: source.total_amount, 53 | transaction_count: source.transaction_count, 54 | description: source.income_description, 55 | income_id: source.income_source_id, 56 | }) 57 | ); 58 | return income_sources; 59 | }); 60 | }) || []; 61 | 62 | setBankIncome(thisUsersIncome); 63 | }, []); 64 | 65 | useEffect(() => { 66 | getIncome(); 67 | }, [getIncome, user.incomeConnected, user.incomeUpdateTime]); 68 | 69 | return ( 70 | 71 | 72 | 73 | Bank income 74 | 75 |

76 | 81 |

82 | {bankIncome.length === 0 ? ( 83 | 84 | Identify income by locating recurring deposits in your bank account. 85 | 86 | ) : ( 87 | 94 | {bankIncome.map((e: BankData, idx) => ( 95 | 104 | 105 | {e.bank_name} 106 | 107 | 108 | {e.description} 109 | 110 | 111 | {e.total_amount.toLocaleString("en-US", { 112 | style: "currency", 113 | currency: hardCodedCurrencyCode, 114 | })}{" "} 115 | 116 | 117 | (from {e.transaction_count} txns) 118 | 119 | ))} 120 | 121 | )} 122 |
123 |
124 | ); 125 | }; 126 | 127 | export default BankIncome; 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plaid Income Sample 2 | 3 | This is a fairly simple application using React on the frontend, NodeJS on the backend and a text file as our database which demonstrates how to make calls against the Plaid Income API. 4 | 5 | ## Running the server 6 | 7 | To get the server running... 8 | 9 | - cd into the server directory and run `npm install` 10 | - Copy over `.env.template` to `.env` 11 | - Copy over `user_data.json.template` to `user_data.json` 12 | - Add your client ID and secret to the .env file you just created 13 | - Run `npm start` (or `npm run watch` if you want to make changes to the file) 14 | 15 | In lieu of setting up an actual database to keep track of user info, we store everything to a flat JSON-encoded file (the `user_data.json` file). It's not very scalable, but it survives server restarts, which is nice. If you do want to start "fresh" with a new user, stop the server, re-copy `user_data.json.template` to `user_data.json`, then restart the server. 16 | 17 | ## Running the client 18 | 19 | To get the client running... 20 | 21 | - cd into the client directory and run `npm install` 22 | - run `npm start`. This will start React in development mode, with automatic reloading and all that fun stuff. 23 | 24 | Go ahead and open up the client page in the browser if it doesn't open automatically (by default, this should be http://localhost:3000) 25 | 26 | ## Using the application 27 | 28 | Would you like to be the first to own a pre-owned hoverboard? Well, you're in luck, Todd's Pre-Owned Hoverboards has financing available! 29 | 30 | When you first start up the app, you'll be prompted to connect to a bank to load up your liabilities, and connect to your Payroll provider (or bank) to load up your sources of income! We provide you with the opportunity to add additional sources of income as well. 31 | 32 | >[!IMPORTANT] 33 | > When adding **liabilities** in the Sandbox environment, you must select a liabilities-compatible account, like a credit card. Picking an account that doesn't work with liabilities will cause link to fail with a "no supported liabilities accounts" error. 34 | > 35 | > When adding **bank income** in the Sandbox environment, you must use a non-OAuth bank (like Houndstooth Bank) and supply the credentials **`user_bank_income`** and **`{}`** and the MFA code of `1234` if you're asked for it. This is different from the credentials you will be shown at the bottom of the page! For more test accounts that work great with Income, see the [docs](https://plaid.com/docs/sandbox/test-credentials/#credit-and-income-testing-credentials). 36 | 37 | In the debug panel (the little accordion component at the bottom of the screen) is a button that simulates what the Income flow might look like if you were to run an income pre-check call with an employer that results in a "HIGH" confidence level. This call only works in Sandbox mode. 38 | 39 | ## (Optional) Receiving webhooks 40 | 41 | When you're developing in the Sandbox environment, Document Income data is available almost immediately. In Production, however, it may take several minutes for Document Income data to be complete. In those situations, you want to listen for the [`INCOME_VERIFICATION`](https://plaid.com/docs/api/products/income/#income_verification) webhook to know when it's safe to fetch document data. 42 | 43 | In our sample application, we have set up a second server on port 8001 to listen for webhooks. If you want to expose this port to the outside world so it can receive webhooks from Plaid, you might want to use a tool like ngrok to create a tunnel between the outside world and localhost:8001. If you have ngrok installed, you can do this by running the following command: 44 | 45 | ``` 46 | ngrok http 8001 47 | ``` 48 | 49 | Once you've done this, you'll need to tell the sample app about the location of your webhook. You can do this by copying the domain that ngrok sets up into your .env file as the WEBHOOK_URL entry. Don't forget the `/server/receive_webhook` path at the end! 50 | 51 | ``` 52 | WEBHOOK_URL=https://1234-56-789-123-456.ngrok.io/server/receive_webhook/ 53 | ``` 54 | 55 | It's best to do this before you start up the server, but you can also submit the new webhook URL into the debug panel (the little accordion component at the bottom of the browser screen) and that will also change the location of the webhook for all future calls. 56 | 57 | For more information on Plaid webhooks, check out our [documentation](https://plaid.com/docs/api/webhooks/) or you can refer to our [video tutorial](https://www.youtube.com/watch?v=0E0KEAVeDyc). 58 | 59 | ### TODOs 60 | 61 | - Display a more meaningful message for when you have pending document data 62 | - Clean up some of the Chakra UI components a bit 63 | - Make the error messaging fancier than an alert box 64 | - Let you know if financing is available so we have a happy ending to our story. 65 | - (Maybe) Add Socket.io support so our server can tell our client when to fetch document data 66 | -------------------------------------------------------------------------------- /client/src/components/PayrollIncome.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useState } from "react"; 2 | import { UserContext } from "./UserContext"; 3 | import LinkLoader, { IncomeType } from "./LinkLoader"; 4 | import { Badge, Box, Flex, Heading, VStack } from "@chakra-ui/layout"; 5 | 6 | interface PayrollData { 7 | employer: string; 8 | ytd_gross: number; 9 | ytd_net: number; 10 | pay_period_gross: number; 11 | pay_period_frequency: string; 12 | downloaded_from_provider: boolean; 13 | payroll_id: string; 14 | } 15 | 16 | /** 17 | * Retrieve payroll income, either downloaded directly from the payroll provider 18 | * or scanned in from documents, and display them in a user-friendly way. 19 | */ 20 | const PayrollIncome = () => { 21 | const [payrollIncome, setPayrollIncome] = useState(Array()); 22 | const hardCodedCurrencyCode = "USD"; 23 | const { user } = useContext(UserContext); 24 | 25 | const getIncome = useCallback(async () => { 26 | const response = await fetch("/appServer/get_payroll_income"); 27 | const data = await response.json(); 28 | console.log("Payroll Income: ", data); 29 | // `items` is an array of objects, each of which contains an array of 30 | // `payroll` income objects, each of which contains an array of `pay_stubs` 31 | const allPayrollIncome = data.items 32 | .filter( 33 | (item: any) => item.status.processing_status === "PROCESSING_COMPLETE" 34 | ) 35 | .reduce((prev: any, curr: any) => prev.concat(curr.payroll_income), []) 36 | .filter( 37 | (e: { pay_stubs: any[] | null }) => 38 | e.pay_stubs != null && e.pay_stubs.length > 0 39 | ); 40 | 41 | const thisUsersIncome: Array = allPayrollIncome.map( 42 | (e: { pay_stubs: any[]; account_id?: string }) => { 43 | // I'm only looking at our most recent pay stub but you may want to look 44 | // at several, depending on your use case 45 | const pay_stub = e.pay_stubs[0]; 46 | // We need to be defensive here because an improperly scanned document 47 | // will show up with a lot of these values as undefined 48 | return { 49 | employer: pay_stub.employer.name || "Unknown", 50 | ytd_gross: pay_stub.earnings.total.ytd_amount || 0, 51 | ytd_net: pay_stub.net_pay.ytd_amount || 0, 52 | pay_period_gross: pay_stub.pay_period_details.gross_earnings || 0, 53 | pay_period_frequency: 54 | pay_stub.pay_period_details.pay_frequency || "Unknown", 55 | downloaded_from_provider: e.account_id !== null, 56 | payroll_id: pay_stub.document_id || "", 57 | }; 58 | } 59 | ); 60 | setPayrollIncome(thisUsersIncome); 61 | }, []); 62 | 63 | useEffect(() => { 64 | getIncome(); 65 | }, [getIncome, user.incomeConnected, user.incomeUpdateTime]); 66 | 67 | return ( 68 | 69 | 70 | 71 | Payroll income 72 | 73 |

74 | 79 |

80 | {payrollIncome.length !== 0 && ( 81 | 82 | {payrollIncome.map((payroll: PayrollData, idx) => ( 83 | 92 | 93 | {payroll.employer} 94 | 95 | 96 | YTD:{" "} 97 | {payroll.ytd_gross.toLocaleString("en-US", { 98 | style: "currency", 99 | currency: hardCodedCurrencyCode, 100 | })} 101 | 102 | 103 | Net:{" "} 104 | {payroll.ytd_net.toLocaleString("en-US", { 105 | style: "currency", 106 | currency: hardCodedCurrencyCode, 107 | })} 108 | 109 | 110 | Salary:{" "} 111 | {payroll.pay_period_gross.toLocaleString("en-US", { 112 | style: "currency", 113 | currency: hardCodedCurrencyCode, 114 | })}{" "} 115 | 116 | {payroll.pay_period_frequency} 117 | 118 | 119 | {payroll.downloaded_from_provider ? ( 120 | Downloaded 121 | ) : ( 122 | Scanned 123 | )} 124 | 125 | ))} 126 | 127 | )} 128 |
129 |
130 | ); 131 | }; 132 | 133 | export default PayrollIncome; 134 | -------------------------------------------------------------------------------- /client/src/components/Liabilities.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, VStack } from "@chakra-ui/layout"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | 4 | enum LiabilityType { 5 | CREDIT_CARD = "Credit", 6 | STUDENT_LOAN = "Student Loan", 7 | MORTGAGE = "Mortgage", 8 | } 9 | 10 | interface LiabilityData { 11 | account_id: string; 12 | name: string; 13 | type: LiabilityType; 14 | orig_amt: number; 15 | amount: number; 16 | percentage: number; 17 | overdue?: boolean; 18 | currencyCode: string; 19 | } 20 | 21 | const Liabilities = () => { 22 | const [userLiabilities, setUserLiabilities] = useState( 23 | Array() 24 | ); 25 | 26 | const loadUpLiabilities = useCallback(async () => { 27 | console.log("Fetching Liability Info!"); 28 | const response = await fetch("/appServer/fetch_liabilities"); 29 | const data = await response.json(); 30 | console.log(data); 31 | 32 | const allLiabilityData: LiabilityData[] = normalizeLiabilityData(data); 33 | setUserLiabilities(normalizeLiabilityData(data)); 34 | console.log(`Here's your cleaned up data:`); 35 | console.table(allLiabilityData); 36 | }, []); 37 | 38 | useEffect(() => { 39 | loadUpLiabilities(); 40 | }, [loadUpLiabilities]); 41 | 42 | return ( 43 | 44 | 45 | Your current loans 46 | 47 | 48 | {userLiabilities.map((liability: LiabilityData) => ( 49 | 58 | 59 | {liability.name} 60 | 61 | {liability.type} 62 | 63 | {liability.amount.toLocaleString("en-US", { 64 | style: "currency", 65 | currency: liability.currencyCode, 66 | })}{" "} 67 | 68 | 69 | ({liability.percentage.toFixed(2)}% APR) 70 | 71 | 72 | ))} 73 | 74 | 75 | ); 76 | }; 77 | 78 | const normalizeLiabilityData = (data: any) => { 79 | // A little bit of work to merge these... 80 | type CreditType = { 81 | account_id: string; 82 | aprs: { 83 | apr_percentage: number; 84 | }[]; 85 | is_overdue?: boolean; 86 | }; 87 | type MortgageType = { 88 | account_id: string; 89 | interest_rate: { 90 | percentage: number; 91 | }; 92 | is_overdue?: boolean; 93 | origination_principal_amount: number; 94 | }; 95 | type StudentLoanType = { 96 | account_id: string; 97 | interest_rate_percentage: number; 98 | is_overdue?: boolean; 99 | origination_principal_amount: number; 100 | }; 101 | 102 | const creditLiabilities = data.liabilities.credit?.map( 103 | (credit: CreditType) => { 104 | const basicAccountInfo = data.accounts.find( 105 | (e: { account_id: string }) => e.account_id === credit.account_id 106 | ); 107 | const newCredit: LiabilityData = { 108 | account_id: credit.account_id, 109 | currencyCode: basicAccountInfo.balances.iso_currency_code, 110 | name: basicAccountInfo.official_name ?? basicAccountInfo.name, 111 | type: LiabilityType.CREDIT_CARD, 112 | orig_amt: 0, 113 | amount: basicAccountInfo.balances.current, 114 | percentage: credit.aprs[0].apr_percentage, 115 | overdue: credit.is_overdue ?? false, 116 | }; 117 | return newCredit; 118 | } 119 | ); 120 | 121 | const mortgageLiabilities = data.liabilities.mortgage?.map( 122 | (mortgage: MortgageType) => { 123 | const basicAccountInfo = data.accounts.find( 124 | (e: { account_id: string }) => e.account_id === mortgage.account_id 125 | ); 126 | const newMortgage: LiabilityData = { 127 | account_id: mortgage.account_id, 128 | currencyCode: basicAccountInfo.balances.iso_currency_code, 129 | name: basicAccountInfo.official_name ?? basicAccountInfo.name, 130 | type: LiabilityType.MORTGAGE, 131 | orig_amt: mortgage.origination_principal_amount, 132 | amount: basicAccountInfo.balances.current, 133 | percentage: mortgage.interest_rate.percentage, 134 | overdue: mortgage.is_overdue ?? false, 135 | }; 136 | return newMortgage; 137 | } 138 | ); 139 | 140 | const studentLiabilities = data.liabilities.student?.map( 141 | (student: StudentLoanType) => { 142 | const basicAccountInfo = data.accounts.find( 143 | (e: { account_id: string }) => e.account_id === student.account_id 144 | ); 145 | const newStudent: LiabilityData = { 146 | account_id: student.account_id, 147 | currencyCode: basicAccountInfo.balances.iso_currency_code, 148 | name: basicAccountInfo.official_name ?? basicAccountInfo.name, 149 | type: LiabilityType.STUDENT_LOAN, 150 | orig_amt: student.origination_principal_amount, 151 | amount: basicAccountInfo.balances.current, 152 | percentage: student.interest_rate_percentage, 153 | overdue: student.is_overdue ?? false, 154 | }; 155 | return newStudent; 156 | } 157 | ); 158 | return [ 159 | ...(creditLiabilities || []), 160 | ...(mortgageLiabilities || []), 161 | ...(studentLiabilities || []), 162 | ]; 163 | }; 164 | 165 | export default Liabilities; 166 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | require("dotenv").config(); 3 | const fs = require("fs/promises"); 4 | const express = require("express"); 5 | const bodyParser = require("body-parser"); 6 | const { Configuration, PlaidEnvironments, PlaidApi } = require("plaid"); 7 | const { v4: uuidv4 } = require("uuid"); 8 | 9 | const APP_PORT = process.env.APP_PORT || 8080; 10 | const USER_DATA_FILE = "user_data.json"; 11 | 12 | // Fields used for the userRecord object 13 | const FIELD_ACCESS_TOKEN = "accessToken"; 14 | const FIELD_USER_TOKEN = "incomeUserToken"; 15 | const FIELD_INCOME_CONNECTED = "incomeConnected"; 16 | const FIELD_PLAID_WEBHOOK_USER_ID = "plaidWebhookUserId"; 17 | const FIELD_USER_ID = "userId"; 18 | 19 | let webhookUrl = 20 | process.env.WEBHOOK_URL || "https://www.example.com/server/receive_webhook"; 21 | 22 | const app = express(); 23 | app.use(bodyParser.urlencoded({ extended: false })); 24 | app.use(bodyParser.json()); 25 | 26 | const server = app.listen(APP_PORT, function () { 27 | console.log(`Server is up and running at http://localhost:${APP_PORT}/`); 28 | }); 29 | 30 | // Set up the Plaid client 31 | const plaidConfig = new Configuration({ 32 | basePath: PlaidEnvironments[process.env.PLAID_ENV], 33 | baseOptions: { 34 | headers: { 35 | "PLAID-CLIENT-ID": process.env.PLAID_CLIENT_ID, 36 | "PLAID-SECRET": process.env.PLAID_SECRET, 37 | "Plaid-Version": "2020-09-14", 38 | }, 39 | }, 40 | }); 41 | 42 | const plaidClient = new PlaidApi(plaidConfig); 43 | 44 | // Instead of using a database to store our user token, we're writing it 45 | // to a flat file. Convenient for demo purposes, a terrible idea for a production 46 | // app. 47 | 48 | /** 49 | * Retrieve our user record from our our flat file 50 | * @returns {Object} userDataObject 51 | */ 52 | const getUserRecord = async function () { 53 | try { 54 | const userData = await fs.readFile(USER_DATA_FILE, { 55 | encoding: "utf8", 56 | }); 57 | const userDataObj = await JSON.parse(userData); 58 | console.log(`Retrieved userData ${userData}`); 59 | return userDataObj; 60 | } catch (error) { 61 | if (error.code === "ENOENT") { 62 | console.log("No user object found. We'll make one from scratch."); 63 | return null; 64 | } 65 | // Might happen first time, if file doesn't exist 66 | console.log("Got an error", error); 67 | return null; 68 | } 69 | }; 70 | 71 | /** 72 | * This loads our user record into memory when we first start up 73 | */ 74 | let userRecord; 75 | (async () => { 76 | userRecord = await getUserRecord(); 77 | if (userRecord == null) { 78 | userRecord = {}; 79 | userRecord[FIELD_ACCESS_TOKEN] = null; 80 | userRecord[FIELD_INCOME_CONNECTED] = false; 81 | userRecord[FIELD_USER_TOKEN] = null; 82 | userRecord[FIELD_USER_ID] = null; 83 | userRecord[FIELD_PLAID_WEBHOOK_USER_ID] = null; 84 | } 85 | // Let's make sure we have a user token created at startup 86 | await fetchOrCreateUserToken(); 87 | })(); 88 | 89 | /** 90 | * Updates the user record in memory and writes it to a file. In a real 91 | * application, you'd be writing to a database. 92 | * @param {string} key 93 | * @param {string | number} val 94 | */ 95 | const updateUserRecord = async function (key, val) { 96 | userRecord[key] = val; 97 | try { 98 | const dataToWrite = JSON.stringify(userRecord); 99 | await fs.writeFile(USER_DATA_FILE, dataToWrite, { 100 | encoding: "utf8", 101 | mode: 0o600, 102 | }); 103 | console.log(`User record ${dataToWrite} written to file.`); 104 | } catch (error) { 105 | console.log("Got an error: ", error); 106 | } 107 | }; 108 | 109 | /** 110 | * Returns the userID associated with this user, or lazily instantiates one. 111 | * In a real application, this would most likely be your signed-in user's ID. 112 | * @returns {string} randomUserId 113 | */ 114 | const getLazyUserID = async function () { 115 | if (userRecord.userId != null && userRecord.userId !== "") { 116 | return userRecord.userId; 117 | } else { 118 | // Let's lazily instantiate it! 119 | const randomUserId = "user_" + uuidv4(); 120 | await updateUserRecord(FIELD_USER_ID, randomUserId); 121 | return randomUserId; 122 | } 123 | }; 124 | 125 | /** 126 | * Checks whether or not a user has granted access to liability info and income 127 | * info based on what's been recorded in our userRecord 128 | */ 129 | app.get("/appServer/get_user_info", async (req, res, next) => { 130 | try { 131 | const income_status = 132 | userRecord[FIELD_INCOME_CONNECTED] != null && 133 | userRecord[FIELD_INCOME_CONNECTED] !== false; 134 | const liability_status = 135 | userRecord[FIELD_ACCESS_TOKEN] != null && 136 | userRecord[FIELD_ACCESS_TOKEN] !== ""; 137 | res.json({ 138 | liability_status: liability_status, 139 | income_status: income_status, 140 | }); 141 | } catch (error) { 142 | next(error); 143 | } 144 | }); 145 | 146 | const basicLinkTokenObject = { 147 | user: { client_user_id: "testUser" }, 148 | client_name: "Todd's Hoverboards", 149 | language: "en", 150 | products: [], 151 | country_codes: ["US"], 152 | }; 153 | 154 | /** 155 | * Generates a link token to be used by the client. Depending on the req.body, 156 | * this will either be a link token used for income, or one used for liabilities 157 | */ 158 | app.post("/appServer/generate_link_token", async (req, res, next) => { 159 | try { 160 | let response; 161 | if (req.body.income === true) { 162 | const userToken = await fetchOrCreateUserToken(); 163 | console.log(`User token returned: ${userToken}`); 164 | const income_verification_object = 165 | req.body.incomeType === "payroll" 166 | ? { income_source_types: ["payroll"] } 167 | : { 168 | income_source_types: ["bank"], 169 | bank_income: { days_requested: 60 }, 170 | }; 171 | 172 | const newIncomeTokenObject = { 173 | ...basicLinkTokenObject, 174 | products: ["income_verification"], 175 | user_token: userToken, 176 | webhook: webhookUrl, 177 | income_verification: income_verification_object, 178 | }; 179 | console.log( 180 | `Here's your token object: ${JSON.stringify(newIncomeTokenObject)}` 181 | ); 182 | response = await plaidClient.linkTokenCreate(newIncomeTokenObject); 183 | } else { 184 | const newLiabilitiesTokenObject = { 185 | ...basicLinkTokenObject, 186 | products: ["liabilities"], 187 | webhook: webhookUrl, 188 | }; 189 | response = await plaidClient.linkTokenCreate(newLiabilitiesTokenObject); 190 | } 191 | res.json(response.data); 192 | } catch (error) { 193 | console.log(`Running into an error!`); 194 | next(error); 195 | } 196 | }); 197 | 198 | /** 199 | * Swap the public token for an access token, so we can access liability info 200 | * in the future 201 | */ 202 | app.post("/appServer/swap_public_token", async (req, res, next) => { 203 | try { 204 | const response = await plaidClient.itemPublicTokenExchange({ 205 | public_token: req.body.public_token, 206 | }); 207 | console.log(`You got back ${JSON.stringify(response.data)}`); 208 | await updateUserRecord(FIELD_ACCESS_TOKEN, response.data.access_token); 209 | res.json({ status: "success" }); 210 | } catch (error) { 211 | next(error); 212 | } 213 | }); 214 | 215 | /** 216 | * Just note that we've successfully connected to at least one source of income. 217 | */ 218 | app.post("/appServer/income_was_successful", async (req, res, next) => { 219 | try { 220 | await updateUserRecord(FIELD_INCOME_CONNECTED, true); 221 | res.json({ status: true }); 222 | } catch (error) { 223 | next(error); 224 | } 225 | }); 226 | 227 | /** 228 | * Grabs liability info for the user and return it as a big ol' JSON object 229 | */ 230 | app.get("/appServer/fetch_liabilities", async (req, res, next) => { 231 | try { 232 | const response = await plaidClient.liabilitiesGet({ 233 | access_token: userRecord[FIELD_ACCESS_TOKEN], 234 | }); 235 | res.json(response.data); 236 | } catch (error) { 237 | next(error); 238 | } 239 | }); 240 | 241 | /** 242 | * Returns the user token if one exists, or calls the /user/create endpoint 243 | * to generate a user token and then return it. 244 | * 245 | * In this application, we call this on demand. If you wanted to create this 246 | * user token as soon as a user signs up for an account, that would be a 247 | * perfectly reasonable solution, as well. 248 | * 249 | * @returns {string} userToken The user token 250 | */ 251 | const fetchOrCreateUserToken = async () => { 252 | const userToken = userRecord[FIELD_USER_TOKEN]; 253 | 254 | if (userToken == null || userToken === "") { 255 | // We're gonna need to generate one! 256 | const userId = await getLazyUserID(); 257 | console.log(`Got a user ID of ${userId}`); 258 | const response = await plaidClient.userCreate({ 259 | client_user_id: userId, 260 | }); 261 | console.log(`New user token is ${JSON.stringify(response.data)}`); 262 | const newUserToken = response.data.user_token; 263 | // We'll save this because this can only be done once per user 264 | await updateUserRecord(FIELD_USER_TOKEN, newUserToken); 265 | // This other user_id that gets returned is used by Plaid's webhooks to 266 | // identify a specific user. In a real application, you would use this to 267 | // know when it's safe to fetch income for a user who uploaded documents 268 | // to Plaid for processing. 269 | const userWebhookId = response.data.user_id; 270 | await updateUserRecord(FIELD_PLAID_WEBHOOK_USER_ID, userWebhookId); 271 | return newUserToken; 272 | } else { 273 | return userToken; 274 | } 275 | }; 276 | 277 | /** 278 | * Simulates what Income precheck might look like if you were to run it with 279 | * an employer that has a "HIGH" confidence level. This call only works 280 | * in the sandbox environment. 281 | */ 282 | app.post("/appServer/simulate_precheck", async (req, res, next) => { 283 | try { 284 | if (process.env.PLAID_ENV !== "sandbox") { 285 | res.status(500).json({ 286 | error: "This hard-coded example only works in the sandbox environment", 287 | }); 288 | return; 289 | } 290 | const targetConfidence = req.body.confidence; 291 | const employerName = 292 | targetConfidence === "HIGH" ? "employer_good" : "Acme, Inc."; 293 | 294 | const response = await plaidClient.creditPayrollIncomePrecheck({ 295 | user_token: userRecord[FIELD_USER_TOKEN], 296 | employer: { 297 | name: employerName, 298 | }, 299 | }); 300 | res.json(response.data); 301 | } catch (error) { 302 | next(error); 303 | } 304 | }); 305 | 306 | /** 307 | * Return payroll income for the user, either downloaded from their payroll 308 | * provider, or scanned in from documents 309 | */ 310 | app.get("/appServer/get_payroll_income", async (req, res, next) => { 311 | try { 312 | const response = await plaidClient.creditPayrollIncomeGet({ 313 | user_token: userRecord[FIELD_USER_TOKEN], 314 | }); 315 | res.json(response.data); 316 | } catch (error) { 317 | next(error); 318 | } 319 | }); 320 | 321 | /** 322 | * Return income for the user, as inferred from their bank transactions. 323 | */ 324 | app.get("/appServer/get_bank_income", async (req, res, next) => { 325 | try { 326 | const response = await plaidClient.creditBankIncomeGet({ 327 | user_token: userRecord[FIELD_USER_TOKEN], 328 | options: { 329 | count: 3, 330 | }, 331 | }); 332 | res.json(response.data); 333 | } catch (error) { 334 | next(error); 335 | } 336 | }); 337 | 338 | /** 339 | * A method for updating our item's webhook URL. For the purpose of Income, 340 | * what's really important is that we're updating the variable in memory that 341 | * we use to generate a Link Token. But it's good form to also update 342 | * any webhooks stored with any access tokens we're actively using, because 343 | * those items will still be pointing to the old (and probably invalid) webhook 344 | * location. 345 | */ 346 | app.post("/server/update_webhook", async (req, res, next) => { 347 | try { 348 | console.log(`Update our webhook with ${JSON.stringify(req.body)}`); 349 | // Update the one we have in memory 350 | webhookUrl = req.body.newUrl; 351 | const access_token = userRecord[FIELD_ACCESS_TOKEN]; 352 | const updateResponse = await plaidClient.itemWebhookUpdate({ 353 | access_token: access_token, 354 | webhook: req.body.newUrl, 355 | }); 356 | res.json(updateResponse.data); 357 | } catch (error) { 358 | next(error); 359 | } 360 | }); 361 | 362 | const errorHandler = function (err, req, res, next) { 363 | console.error(`Your error: ${JSON.stringify(err)}`); 364 | console.error(err); 365 | if (err.response?.data != null) { 366 | res.status(500).send(err.response.data); 367 | } else { 368 | res.status(500).send({ 369 | error_code: "OTHER_ERROR", 370 | error_message: "I got some other message on the server.", 371 | }); 372 | } 373 | }; 374 | app.use(errorHandler); 375 | 376 | /** 377 | * For development purposes, we're running a second server on port 8001 that's 378 | * used to receive webhooks. This is so we can easily expose this endpoint to 379 | * the external world using ngrok without exposing the rest of our application. 380 | * See this tutorial for more details on using ngrok and webhooks: 381 | * https://www.youtube.com/watch?v=0E0KEAVeDyc 382 | */ 383 | 384 | const WEBHOOK_PORT = process.env.WEBHOOK_PORT || 8001; 385 | 386 | const webhookApp = express(); 387 | webhookApp.use(bodyParser.urlencoded({ extended: false })); 388 | webhookApp.use(bodyParser.json()); 389 | 390 | const webhookServer = webhookApp.listen(WEBHOOK_PORT, function () { 391 | console.log( 392 | `Webhook receiver is up and running at http://localhost:${WEBHOOK_PORT}/` 393 | ); 394 | }); 395 | 396 | webhookApp.post("/server/receive_webhook", async (req, res, next) => { 397 | try { 398 | console.log("Webhook received:"); 399 | console.dir(req.body, { colors: true, depth: null }); 400 | 401 | // TODO: Verify webhook. 402 | const product = req.body.webhook_type; 403 | const code = req.body.webhook_code; 404 | switch (product) { 405 | case "ITEM": 406 | handleItemWebhook(code, req.body); 407 | break; 408 | case "INCOME": 409 | handleIncomeWebhook(code, req.body); 410 | break; 411 | default: 412 | console.log(`Can't handle webhook product ${product}`); 413 | break; 414 | } 415 | res.json({ status: "received" }); 416 | } catch (error) { 417 | next(error); 418 | } 419 | }); 420 | 421 | function handleIncomeWebhook(code, requestBody) { 422 | switch (code) { 423 | case "INCOME_VERIFICATION": 424 | const verificationStatus = requestBody.verification_status; 425 | const webhookUserId = requestBody.user_id; 426 | if (verificationStatus === "VERIFICATION_STATUS_PROCESSING_COMPLETE") { 427 | console.log( 428 | `Plaid has successfully completed payroll processing for the user with the webhook identifier of ${webhookUserId}. You should probably call /paystubs/get to refresh your data.` 429 | ); 430 | } else if ( 431 | verificationStatus === "VERIFICATION_STATUS_PROCESSING_FAILED" 432 | ) { 433 | console.log( 434 | `Plaid had trouble processing documents for the user with the webhook identifier of ${webhookUserId}. You should ask them to try again.` 435 | ); 436 | } else if ( 437 | verificationStatus === "VERIFICATION_STATUS_PENDING_APPROVAL" 438 | ) { 439 | console.log( 440 | `Plaid is waiting for the user with the webhook identifier of ${webhookUserId} to approve their income verification.` 441 | ); 442 | } 443 | break; 444 | default: 445 | console.log(`Can't handle webhook code ${code}`); 446 | break; 447 | } 448 | } 449 | 450 | function handleItemWebhook(code, requestBody) { 451 | switch (code) { 452 | case "ERROR": 453 | console.log( 454 | `I received this error: ${requestBody.error.error_message}| should probably ask this user to connect to their bank` 455 | ); 456 | break; 457 | case "NEW_ACCOUNTS_AVAILABLE": 458 | console.log( 459 | `There are new accounts available at this Financial Institution! (Id: ${requestBody.item_id}) We might want to ask the user to share them with us` 460 | ); 461 | break; 462 | case "PENDING_EXPIRATION": 463 | console.log( 464 | `We should tell our user to reconnect their bank with Plaid so there's no disruption to their service` 465 | ); 466 | break; 467 | case "USER_PERMISSION_REVOKED": 468 | console.log( 469 | `The user revoked access to this item. We should remove it from our records` 470 | ); 471 | break; 472 | case "WEBHOOK_UPDATE_ACKNOWLEDGED": 473 | console.log(`Future webhooks will be sent to this endpoint.`); 474 | break; 475 | default: 476 | console.log(`Can't handle webhook code ${code}`); 477 | break; 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /server/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "server", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "body-parser": "^1.20.3", 13 | "dotenv": "^16.6.1", 14 | "express": "^4.21.2", 15 | "nodemon": "^3.1.10", 16 | "plaid": "^39.1.0", 17 | "uuid": "^9.0.1" 18 | }, 19 | "engines": { 20 | "node": ">=14.0.0" 21 | } 22 | }, 23 | "node_modules/accepts": { 24 | "version": "1.3.8", 25 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 26 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 27 | "dependencies": { 28 | "mime-types": "~2.1.34", 29 | "negotiator": "0.6.3" 30 | }, 31 | "engines": { 32 | "node": ">= 0.6" 33 | } 34 | }, 35 | "node_modules/anymatch": { 36 | "version": "3.1.3", 37 | "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", 38 | "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", 39 | "dependencies": { 40 | "normalize-path": "^3.0.0", 41 | "picomatch": "^2.0.4" 42 | }, 43 | "engines": { 44 | "node": ">= 8" 45 | } 46 | }, 47 | "node_modules/array-flatten": { 48 | "version": "1.1.1", 49 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 50 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 51 | }, 52 | "node_modules/asynckit": { 53 | "version": "0.4.0", 54 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 55 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 56 | }, 57 | "node_modules/axios": { 58 | "version": "1.13.2", 59 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", 60 | "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", 61 | "dependencies": { 62 | "follow-redirects": "^1.15.6", 63 | "form-data": "^4.0.4", 64 | "proxy-from-env": "^1.1.0" 65 | } 66 | }, 67 | "node_modules/balanced-match": { 68 | "version": "1.0.2", 69 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 70 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 71 | }, 72 | "node_modules/binary-extensions": { 73 | "version": "2.3.0", 74 | "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", 75 | "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", 76 | "engines": { 77 | "node": ">=8" 78 | }, 79 | "funding": { 80 | "url": "https://github.com/sponsors/sindresorhus" 81 | } 82 | }, 83 | "node_modules/body-parser": { 84 | "version": "1.20.3", 85 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", 86 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 87 | "dependencies": { 88 | "bytes": "3.1.2", 89 | "content-type": "~1.0.5", 90 | "debug": "2.6.9", 91 | "depd": "2.0.0", 92 | "destroy": "1.2.0", 93 | "http-errors": "2.0.0", 94 | "iconv-lite": "0.4.24", 95 | "on-finished": "2.4.1", 96 | "qs": "6.13.0", 97 | "raw-body": "2.5.2", 98 | "type-is": "~1.6.18", 99 | "unpipe": "1.0.0" 100 | }, 101 | "engines": { 102 | "node": ">= 0.8", 103 | "npm": "1.2.8000 || >= 1.4.16" 104 | } 105 | }, 106 | "node_modules/brace-expansion": { 107 | "version": "1.1.12", 108 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", 109 | "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 110 | "dependencies": { 111 | "balanced-match": "^1.0.0", 112 | "concat-map": "0.0.1" 113 | } 114 | }, 115 | "node_modules/braces": { 116 | "version": "3.0.3", 117 | "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", 118 | "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 119 | "dependencies": { 120 | "fill-range": "^7.1.1" 121 | }, 122 | "engines": { 123 | "node": ">=8" 124 | } 125 | }, 126 | "node_modules/bytes": { 127 | "version": "3.1.2", 128 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 129 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", 130 | "engines": { 131 | "node": ">= 0.8" 132 | } 133 | }, 134 | "node_modules/call-bind-apply-helpers": { 135 | "version": "1.0.2", 136 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 137 | "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 138 | "dependencies": { 139 | "es-errors": "^1.3.0", 140 | "function-bind": "^1.1.2" 141 | }, 142 | "engines": { 143 | "node": ">= 0.4" 144 | } 145 | }, 146 | "node_modules/call-bound": { 147 | "version": "1.0.4", 148 | "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", 149 | "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", 150 | "dependencies": { 151 | "call-bind-apply-helpers": "^1.0.2", 152 | "get-intrinsic": "^1.3.0" 153 | }, 154 | "engines": { 155 | "node": ">= 0.4" 156 | }, 157 | "funding": { 158 | "url": "https://github.com/sponsors/ljharb" 159 | } 160 | }, 161 | "node_modules/chokidar": { 162 | "version": "3.6.0", 163 | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", 164 | "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", 165 | "dependencies": { 166 | "anymatch": "~3.1.2", 167 | "braces": "~3.0.2", 168 | "glob-parent": "~5.1.2", 169 | "is-binary-path": "~2.1.0", 170 | "is-glob": "~4.0.1", 171 | "normalize-path": "~3.0.0", 172 | "readdirp": "~3.6.0" 173 | }, 174 | "engines": { 175 | "node": ">= 8.10.0" 176 | }, 177 | "funding": { 178 | "url": "https://paulmillr.com/funding/" 179 | }, 180 | "optionalDependencies": { 181 | "fsevents": "~2.3.2" 182 | } 183 | }, 184 | "node_modules/combined-stream": { 185 | "version": "1.0.8", 186 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 187 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 188 | "dependencies": { 189 | "delayed-stream": "~1.0.0" 190 | }, 191 | "engines": { 192 | "node": ">= 0.8" 193 | } 194 | }, 195 | "node_modules/concat-map": { 196 | "version": "0.0.1", 197 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 198 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" 199 | }, 200 | "node_modules/content-disposition": { 201 | "version": "0.5.4", 202 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 203 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 204 | "dependencies": { 205 | "safe-buffer": "5.2.1" 206 | }, 207 | "engines": { 208 | "node": ">= 0.6" 209 | } 210 | }, 211 | "node_modules/content-type": { 212 | "version": "1.0.5", 213 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 214 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", 215 | "engines": { 216 | "node": ">= 0.6" 217 | } 218 | }, 219 | "node_modules/cookie": { 220 | "version": "0.7.1", 221 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", 222 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", 223 | "engines": { 224 | "node": ">= 0.6" 225 | } 226 | }, 227 | "node_modules/cookie-signature": { 228 | "version": "1.0.6", 229 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 230 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 231 | }, 232 | "node_modules/debug": { 233 | "version": "2.6.9", 234 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 235 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 236 | "dependencies": { 237 | "ms": "2.0.0" 238 | } 239 | }, 240 | "node_modules/delayed-stream": { 241 | "version": "1.0.0", 242 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 243 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 244 | "engines": { 245 | "node": ">=0.4.0" 246 | } 247 | }, 248 | "node_modules/depd": { 249 | "version": "2.0.0", 250 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 251 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 252 | "engines": { 253 | "node": ">= 0.8" 254 | } 255 | }, 256 | "node_modules/destroy": { 257 | "version": "1.2.0", 258 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 259 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", 260 | "engines": { 261 | "node": ">= 0.8", 262 | "npm": "1.2.8000 || >= 1.4.16" 263 | } 264 | }, 265 | "node_modules/dotenv": { 266 | "version": "16.6.1", 267 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", 268 | "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", 269 | "engines": { 270 | "node": ">=12" 271 | }, 272 | "funding": { 273 | "url": "https://dotenvx.com" 274 | } 275 | }, 276 | "node_modules/dunder-proto": { 277 | "version": "1.0.1", 278 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 279 | "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 280 | "dependencies": { 281 | "call-bind-apply-helpers": "^1.0.1", 282 | "es-errors": "^1.3.0", 283 | "gopd": "^1.2.0" 284 | }, 285 | "engines": { 286 | "node": ">= 0.4" 287 | } 288 | }, 289 | "node_modules/ee-first": { 290 | "version": "1.1.1", 291 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 292 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 293 | }, 294 | "node_modules/encodeurl": { 295 | "version": "2.0.0", 296 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 297 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", 298 | "engines": { 299 | "node": ">= 0.8" 300 | } 301 | }, 302 | "node_modules/es-define-property": { 303 | "version": "1.0.1", 304 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 305 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 306 | "engines": { 307 | "node": ">= 0.4" 308 | } 309 | }, 310 | "node_modules/es-errors": { 311 | "version": "1.3.0", 312 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 313 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 314 | "engines": { 315 | "node": ">= 0.4" 316 | } 317 | }, 318 | "node_modules/es-object-atoms": { 319 | "version": "1.1.1", 320 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 321 | "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 322 | "dependencies": { 323 | "es-errors": "^1.3.0" 324 | }, 325 | "engines": { 326 | "node": ">= 0.4" 327 | } 328 | }, 329 | "node_modules/es-set-tostringtag": { 330 | "version": "2.1.0", 331 | "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", 332 | "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 333 | "dependencies": { 334 | "es-errors": "^1.3.0", 335 | "get-intrinsic": "^1.2.6", 336 | "has-tostringtag": "^1.0.2", 337 | "hasown": "^2.0.2" 338 | }, 339 | "engines": { 340 | "node": ">= 0.4" 341 | } 342 | }, 343 | "node_modules/escape-html": { 344 | "version": "1.0.3", 345 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 346 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 347 | }, 348 | "node_modules/etag": { 349 | "version": "1.8.1", 350 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 351 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", 352 | "engines": { 353 | "node": ">= 0.6" 354 | } 355 | }, 356 | "node_modules/express": { 357 | "version": "4.21.2", 358 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", 359 | "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", 360 | "dependencies": { 361 | "accepts": "~1.3.8", 362 | "array-flatten": "1.1.1", 363 | "body-parser": "1.20.3", 364 | "content-disposition": "0.5.4", 365 | "content-type": "~1.0.4", 366 | "cookie": "0.7.1", 367 | "cookie-signature": "1.0.6", 368 | "debug": "2.6.9", 369 | "depd": "2.0.0", 370 | "encodeurl": "~2.0.0", 371 | "escape-html": "~1.0.3", 372 | "etag": "~1.8.1", 373 | "finalhandler": "1.3.1", 374 | "fresh": "0.5.2", 375 | "http-errors": "2.0.0", 376 | "merge-descriptors": "1.0.3", 377 | "methods": "~1.1.2", 378 | "on-finished": "2.4.1", 379 | "parseurl": "~1.3.3", 380 | "path-to-regexp": "0.1.12", 381 | "proxy-addr": "~2.0.7", 382 | "qs": "6.13.0", 383 | "range-parser": "~1.2.1", 384 | "safe-buffer": "5.2.1", 385 | "send": "0.19.0", 386 | "serve-static": "1.16.2", 387 | "setprototypeof": "1.2.0", 388 | "statuses": "2.0.1", 389 | "type-is": "~1.6.18", 390 | "utils-merge": "1.0.1", 391 | "vary": "~1.1.2" 392 | }, 393 | "engines": { 394 | "node": ">= 0.10.0" 395 | }, 396 | "funding": { 397 | "type": "opencollective", 398 | "url": "https://opencollective.com/express" 399 | } 400 | }, 401 | "node_modules/fill-range": { 402 | "version": "7.1.1", 403 | "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", 404 | "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 405 | "dependencies": { 406 | "to-regex-range": "^5.0.1" 407 | }, 408 | "engines": { 409 | "node": ">=8" 410 | } 411 | }, 412 | "node_modules/finalhandler": { 413 | "version": "1.3.1", 414 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 415 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 416 | "dependencies": { 417 | "debug": "2.6.9", 418 | "encodeurl": "~2.0.0", 419 | "escape-html": "~1.0.3", 420 | "on-finished": "2.4.1", 421 | "parseurl": "~1.3.3", 422 | "statuses": "2.0.1", 423 | "unpipe": "~1.0.0" 424 | }, 425 | "engines": { 426 | "node": ">= 0.8" 427 | } 428 | }, 429 | "node_modules/follow-redirects": { 430 | "version": "1.15.11", 431 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", 432 | "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", 433 | "funding": [ 434 | { 435 | "type": "individual", 436 | "url": "https://github.com/sponsors/RubenVerborgh" 437 | } 438 | ], 439 | "engines": { 440 | "node": ">=4.0" 441 | }, 442 | "peerDependenciesMeta": { 443 | "debug": { 444 | "optional": true 445 | } 446 | } 447 | }, 448 | "node_modules/form-data": { 449 | "version": "4.0.4", 450 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", 451 | "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", 452 | "dependencies": { 453 | "asynckit": "^0.4.0", 454 | "combined-stream": "^1.0.8", 455 | "es-set-tostringtag": "^2.1.0", 456 | "hasown": "^2.0.2", 457 | "mime-types": "^2.1.12" 458 | }, 459 | "engines": { 460 | "node": ">= 6" 461 | } 462 | }, 463 | "node_modules/forwarded": { 464 | "version": "0.2.0", 465 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 466 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 467 | "engines": { 468 | "node": ">= 0.6" 469 | } 470 | }, 471 | "node_modules/fresh": { 472 | "version": "0.5.2", 473 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 474 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", 475 | "engines": { 476 | "node": ">= 0.6" 477 | } 478 | }, 479 | "node_modules/fsevents": { 480 | "version": "2.3.3", 481 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 482 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 483 | "hasInstallScript": true, 484 | "optional": true, 485 | "os": [ 486 | "darwin" 487 | ], 488 | "engines": { 489 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 490 | } 491 | }, 492 | "node_modules/function-bind": { 493 | "version": "1.1.2", 494 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 495 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 496 | "funding": { 497 | "url": "https://github.com/sponsors/ljharb" 498 | } 499 | }, 500 | "node_modules/get-intrinsic": { 501 | "version": "1.3.0", 502 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 503 | "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 504 | "dependencies": { 505 | "call-bind-apply-helpers": "^1.0.2", 506 | "es-define-property": "^1.0.1", 507 | "es-errors": "^1.3.0", 508 | "es-object-atoms": "^1.1.1", 509 | "function-bind": "^1.1.2", 510 | "get-proto": "^1.0.1", 511 | "gopd": "^1.2.0", 512 | "has-symbols": "^1.1.0", 513 | "hasown": "^2.0.2", 514 | "math-intrinsics": "^1.1.0" 515 | }, 516 | "engines": { 517 | "node": ">= 0.4" 518 | }, 519 | "funding": { 520 | "url": "https://github.com/sponsors/ljharb" 521 | } 522 | }, 523 | "node_modules/get-proto": { 524 | "version": "1.0.1", 525 | "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 526 | "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 527 | "dependencies": { 528 | "dunder-proto": "^1.0.1", 529 | "es-object-atoms": "^1.0.0" 530 | }, 531 | "engines": { 532 | "node": ">= 0.4" 533 | } 534 | }, 535 | "node_modules/glob-parent": { 536 | "version": "5.1.2", 537 | "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 538 | "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 539 | "dependencies": { 540 | "is-glob": "^4.0.1" 541 | }, 542 | "engines": { 543 | "node": ">= 6" 544 | } 545 | }, 546 | "node_modules/gopd": { 547 | "version": "1.2.0", 548 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 549 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 550 | "engines": { 551 | "node": ">= 0.4" 552 | }, 553 | "funding": { 554 | "url": "https://github.com/sponsors/ljharb" 555 | } 556 | }, 557 | "node_modules/has-flag": { 558 | "version": "3.0.0", 559 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", 560 | "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", 561 | "engines": { 562 | "node": ">=4" 563 | } 564 | }, 565 | "node_modules/has-symbols": { 566 | "version": "1.1.0", 567 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 568 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 569 | "engines": { 570 | "node": ">= 0.4" 571 | }, 572 | "funding": { 573 | "url": "https://github.com/sponsors/ljharb" 574 | } 575 | }, 576 | "node_modules/has-tostringtag": { 577 | "version": "1.0.2", 578 | "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", 579 | "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 580 | "dependencies": { 581 | "has-symbols": "^1.0.3" 582 | }, 583 | "engines": { 584 | "node": ">= 0.4" 585 | }, 586 | "funding": { 587 | "url": "https://github.com/sponsors/ljharb" 588 | } 589 | }, 590 | "node_modules/hasown": { 591 | "version": "2.0.2", 592 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 593 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 594 | "dependencies": { 595 | "function-bind": "^1.1.2" 596 | }, 597 | "engines": { 598 | "node": ">= 0.4" 599 | } 600 | }, 601 | "node_modules/http-errors": { 602 | "version": "2.0.0", 603 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 604 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 605 | "dependencies": { 606 | "depd": "2.0.0", 607 | "inherits": "2.0.4", 608 | "setprototypeof": "1.2.0", 609 | "statuses": "2.0.1", 610 | "toidentifier": "1.0.1" 611 | }, 612 | "engines": { 613 | "node": ">= 0.8" 614 | } 615 | }, 616 | "node_modules/iconv-lite": { 617 | "version": "0.4.24", 618 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 619 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 620 | "dependencies": { 621 | "safer-buffer": ">= 2.1.2 < 3" 622 | }, 623 | "engines": { 624 | "node": ">=0.10.0" 625 | } 626 | }, 627 | "node_modules/ignore-by-default": { 628 | "version": "1.0.1", 629 | "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", 630 | "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==" 631 | }, 632 | "node_modules/inherits": { 633 | "version": "2.0.4", 634 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 635 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 636 | }, 637 | "node_modules/ipaddr.js": { 638 | "version": "1.9.1", 639 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 640 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 641 | "engines": { 642 | "node": ">= 0.10" 643 | } 644 | }, 645 | "node_modules/is-binary-path": { 646 | "version": "2.1.0", 647 | "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", 648 | "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", 649 | "dependencies": { 650 | "binary-extensions": "^2.0.0" 651 | }, 652 | "engines": { 653 | "node": ">=8" 654 | } 655 | }, 656 | "node_modules/is-extglob": { 657 | "version": "2.1.1", 658 | "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 659 | "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 660 | "engines": { 661 | "node": ">=0.10.0" 662 | } 663 | }, 664 | "node_modules/is-glob": { 665 | "version": "4.0.3", 666 | "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 667 | "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 668 | "dependencies": { 669 | "is-extglob": "^2.1.1" 670 | }, 671 | "engines": { 672 | "node": ">=0.10.0" 673 | } 674 | }, 675 | "node_modules/is-number": { 676 | "version": "7.0.0", 677 | "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", 678 | "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", 679 | "engines": { 680 | "node": ">=0.12.0" 681 | } 682 | }, 683 | "node_modules/math-intrinsics": { 684 | "version": "1.1.0", 685 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 686 | "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 687 | "engines": { 688 | "node": ">= 0.4" 689 | } 690 | }, 691 | "node_modules/media-typer": { 692 | "version": "0.3.0", 693 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 694 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", 695 | "engines": { 696 | "node": ">= 0.6" 697 | } 698 | }, 699 | "node_modules/merge-descriptors": { 700 | "version": "1.0.3", 701 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 702 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", 703 | "funding": { 704 | "url": "https://github.com/sponsors/sindresorhus" 705 | } 706 | }, 707 | "node_modules/methods": { 708 | "version": "1.1.2", 709 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 710 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", 711 | "engines": { 712 | "node": ">= 0.6" 713 | } 714 | }, 715 | "node_modules/mime": { 716 | "version": "1.6.0", 717 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 718 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 719 | "bin": { 720 | "mime": "cli.js" 721 | }, 722 | "engines": { 723 | "node": ">=4" 724 | } 725 | }, 726 | "node_modules/mime-db": { 727 | "version": "1.52.0", 728 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 729 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 730 | "engines": { 731 | "node": ">= 0.6" 732 | } 733 | }, 734 | "node_modules/mime-types": { 735 | "version": "2.1.35", 736 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 737 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 738 | "dependencies": { 739 | "mime-db": "1.52.0" 740 | }, 741 | "engines": { 742 | "node": ">= 0.6" 743 | } 744 | }, 745 | "node_modules/minimatch": { 746 | "version": "3.1.2", 747 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 748 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 749 | "dependencies": { 750 | "brace-expansion": "^1.1.7" 751 | }, 752 | "engines": { 753 | "node": "*" 754 | } 755 | }, 756 | "node_modules/ms": { 757 | "version": "2.0.0", 758 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 759 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 760 | }, 761 | "node_modules/negotiator": { 762 | "version": "0.6.3", 763 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 764 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 765 | "engines": { 766 | "node": ">= 0.6" 767 | } 768 | }, 769 | "node_modules/nodemon": { 770 | "version": "3.1.10", 771 | "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", 772 | "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", 773 | "dependencies": { 774 | "chokidar": "^3.5.2", 775 | "debug": "^4", 776 | "ignore-by-default": "^1.0.1", 777 | "minimatch": "^3.1.2", 778 | "pstree.remy": "^1.1.8", 779 | "semver": "^7.5.3", 780 | "simple-update-notifier": "^2.0.0", 781 | "supports-color": "^5.5.0", 782 | "touch": "^3.1.0", 783 | "undefsafe": "^2.0.5" 784 | }, 785 | "bin": { 786 | "nodemon": "bin/nodemon.js" 787 | }, 788 | "engines": { 789 | "node": ">=10" 790 | }, 791 | "funding": { 792 | "type": "opencollective", 793 | "url": "https://opencollective.com/nodemon" 794 | } 795 | }, 796 | "node_modules/nodemon/node_modules/debug": { 797 | "version": "4.4.3", 798 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 799 | "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 800 | "dependencies": { 801 | "ms": "^2.1.3" 802 | }, 803 | "engines": { 804 | "node": ">=6.0" 805 | }, 806 | "peerDependenciesMeta": { 807 | "supports-color": { 808 | "optional": true 809 | } 810 | } 811 | }, 812 | "node_modules/nodemon/node_modules/ms": { 813 | "version": "2.1.3", 814 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 815 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 816 | }, 817 | "node_modules/normalize-path": { 818 | "version": "3.0.0", 819 | "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", 820 | "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", 821 | "engines": { 822 | "node": ">=0.10.0" 823 | } 824 | }, 825 | "node_modules/object-inspect": { 826 | "version": "1.13.4", 827 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", 828 | "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", 829 | "engines": { 830 | "node": ">= 0.4" 831 | }, 832 | "funding": { 833 | "url": "https://github.com/sponsors/ljharb" 834 | } 835 | }, 836 | "node_modules/on-finished": { 837 | "version": "2.4.1", 838 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 839 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 840 | "dependencies": { 841 | "ee-first": "1.1.1" 842 | }, 843 | "engines": { 844 | "node": ">= 0.8" 845 | } 846 | }, 847 | "node_modules/parseurl": { 848 | "version": "1.3.3", 849 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 850 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", 851 | "engines": { 852 | "node": ">= 0.8" 853 | } 854 | }, 855 | "node_modules/path-to-regexp": { 856 | "version": "0.1.12", 857 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", 858 | "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" 859 | }, 860 | "node_modules/picomatch": { 861 | "version": "2.3.1", 862 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 863 | "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 864 | "engines": { 865 | "node": ">=8.6" 866 | }, 867 | "funding": { 868 | "url": "https://github.com/sponsors/jonschlinkert" 869 | } 870 | }, 871 | "node_modules/plaid": { 872 | "version": "39.1.0", 873 | "resolved": "https://registry.npmjs.org/plaid/-/plaid-39.1.0.tgz", 874 | "integrity": "sha512-i1D3DMXReWwjBC+phMgdW8k9c0OCYw0IH2ZOdnqGM6ZCAFHDeFxgvcLdM9uwNFZdFeu7VEd9MJRj9QTKEQjKBQ==", 875 | "dependencies": { 876 | "axios": "^1.7.4" 877 | }, 878 | "engines": { 879 | "node": ">=10.0.0" 880 | } 881 | }, 882 | "node_modules/proxy-addr": { 883 | "version": "2.0.7", 884 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 885 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 886 | "dependencies": { 887 | "forwarded": "0.2.0", 888 | "ipaddr.js": "1.9.1" 889 | }, 890 | "engines": { 891 | "node": ">= 0.10" 892 | } 893 | }, 894 | "node_modules/proxy-from-env": { 895 | "version": "1.1.0", 896 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 897 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 898 | }, 899 | "node_modules/pstree.remy": { 900 | "version": "1.1.8", 901 | "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", 902 | "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==" 903 | }, 904 | "node_modules/qs": { 905 | "version": "6.13.0", 906 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 907 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 908 | "dependencies": { 909 | "side-channel": "^1.0.6" 910 | }, 911 | "engines": { 912 | "node": ">=0.6" 913 | }, 914 | "funding": { 915 | "url": "https://github.com/sponsors/ljharb" 916 | } 917 | }, 918 | "node_modules/range-parser": { 919 | "version": "1.2.1", 920 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 921 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", 922 | "engines": { 923 | "node": ">= 0.6" 924 | } 925 | }, 926 | "node_modules/raw-body": { 927 | "version": "2.5.2", 928 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", 929 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 930 | "dependencies": { 931 | "bytes": "3.1.2", 932 | "http-errors": "2.0.0", 933 | "iconv-lite": "0.4.24", 934 | "unpipe": "1.0.0" 935 | }, 936 | "engines": { 937 | "node": ">= 0.8" 938 | } 939 | }, 940 | "node_modules/readdirp": { 941 | "version": "3.6.0", 942 | "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", 943 | "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", 944 | "dependencies": { 945 | "picomatch": "^2.2.1" 946 | }, 947 | "engines": { 948 | "node": ">=8.10.0" 949 | } 950 | }, 951 | "node_modules/safe-buffer": { 952 | "version": "5.2.1", 953 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 954 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 955 | "funding": [ 956 | { 957 | "type": "github", 958 | "url": "https://github.com/sponsors/feross" 959 | }, 960 | { 961 | "type": "patreon", 962 | "url": "https://www.patreon.com/feross" 963 | }, 964 | { 965 | "type": "consulting", 966 | "url": "https://feross.org/support" 967 | } 968 | ] 969 | }, 970 | "node_modules/safer-buffer": { 971 | "version": "2.1.2", 972 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 973 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 974 | }, 975 | "node_modules/semver": { 976 | "version": "7.7.3", 977 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", 978 | "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", 979 | "bin": { 980 | "semver": "bin/semver.js" 981 | }, 982 | "engines": { 983 | "node": ">=10" 984 | } 985 | }, 986 | "node_modules/send": { 987 | "version": "0.19.0", 988 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 989 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 990 | "dependencies": { 991 | "debug": "2.6.9", 992 | "depd": "2.0.0", 993 | "destroy": "1.2.0", 994 | "encodeurl": "~1.0.2", 995 | "escape-html": "~1.0.3", 996 | "etag": "~1.8.1", 997 | "fresh": "0.5.2", 998 | "http-errors": "2.0.0", 999 | "mime": "1.6.0", 1000 | "ms": "2.1.3", 1001 | "on-finished": "2.4.1", 1002 | "range-parser": "~1.2.1", 1003 | "statuses": "2.0.1" 1004 | }, 1005 | "engines": { 1006 | "node": ">= 0.8.0" 1007 | } 1008 | }, 1009 | "node_modules/send/node_modules/encodeurl": { 1010 | "version": "1.0.2", 1011 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 1012 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", 1013 | "engines": { 1014 | "node": ">= 0.8" 1015 | } 1016 | }, 1017 | "node_modules/send/node_modules/ms": { 1018 | "version": "2.1.3", 1019 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1020 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 1021 | }, 1022 | "node_modules/serve-static": { 1023 | "version": "1.16.2", 1024 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 1025 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 1026 | "dependencies": { 1027 | "encodeurl": "~2.0.0", 1028 | "escape-html": "~1.0.3", 1029 | "parseurl": "~1.3.3", 1030 | "send": "0.19.0" 1031 | }, 1032 | "engines": { 1033 | "node": ">= 0.8.0" 1034 | } 1035 | }, 1036 | "node_modules/setprototypeof": { 1037 | "version": "1.2.0", 1038 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 1039 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 1040 | }, 1041 | "node_modules/side-channel": { 1042 | "version": "1.1.0", 1043 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", 1044 | "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 1045 | "dependencies": { 1046 | "es-errors": "^1.3.0", 1047 | "object-inspect": "^1.13.3", 1048 | "side-channel-list": "^1.0.0", 1049 | "side-channel-map": "^1.0.1", 1050 | "side-channel-weakmap": "^1.0.2" 1051 | }, 1052 | "engines": { 1053 | "node": ">= 0.4" 1054 | }, 1055 | "funding": { 1056 | "url": "https://github.com/sponsors/ljharb" 1057 | } 1058 | }, 1059 | "node_modules/side-channel-list": { 1060 | "version": "1.0.0", 1061 | "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", 1062 | "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", 1063 | "dependencies": { 1064 | "es-errors": "^1.3.0", 1065 | "object-inspect": "^1.13.3" 1066 | }, 1067 | "engines": { 1068 | "node": ">= 0.4" 1069 | }, 1070 | "funding": { 1071 | "url": "https://github.com/sponsors/ljharb" 1072 | } 1073 | }, 1074 | "node_modules/side-channel-map": { 1075 | "version": "1.0.1", 1076 | "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", 1077 | "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", 1078 | "dependencies": { 1079 | "call-bound": "^1.0.2", 1080 | "es-errors": "^1.3.0", 1081 | "get-intrinsic": "^1.2.5", 1082 | "object-inspect": "^1.13.3" 1083 | }, 1084 | "engines": { 1085 | "node": ">= 0.4" 1086 | }, 1087 | "funding": { 1088 | "url": "https://github.com/sponsors/ljharb" 1089 | } 1090 | }, 1091 | "node_modules/side-channel-weakmap": { 1092 | "version": "1.0.2", 1093 | "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", 1094 | "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", 1095 | "dependencies": { 1096 | "call-bound": "^1.0.2", 1097 | "es-errors": "^1.3.0", 1098 | "get-intrinsic": "^1.2.5", 1099 | "object-inspect": "^1.13.3", 1100 | "side-channel-map": "^1.0.1" 1101 | }, 1102 | "engines": { 1103 | "node": ">= 0.4" 1104 | }, 1105 | "funding": { 1106 | "url": "https://github.com/sponsors/ljharb" 1107 | } 1108 | }, 1109 | "node_modules/simple-update-notifier": { 1110 | "version": "2.0.0", 1111 | "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", 1112 | "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", 1113 | "dependencies": { 1114 | "semver": "^7.5.3" 1115 | }, 1116 | "engines": { 1117 | "node": ">=10" 1118 | } 1119 | }, 1120 | "node_modules/statuses": { 1121 | "version": "2.0.1", 1122 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 1123 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", 1124 | "engines": { 1125 | "node": ">= 0.8" 1126 | } 1127 | }, 1128 | "node_modules/supports-color": { 1129 | "version": "5.5.0", 1130 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", 1131 | "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", 1132 | "dependencies": { 1133 | "has-flag": "^3.0.0" 1134 | }, 1135 | "engines": { 1136 | "node": ">=4" 1137 | } 1138 | }, 1139 | "node_modules/to-regex-range": { 1140 | "version": "5.0.1", 1141 | "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 1142 | "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 1143 | "dependencies": { 1144 | "is-number": "^7.0.0" 1145 | }, 1146 | "engines": { 1147 | "node": ">=8.0" 1148 | } 1149 | }, 1150 | "node_modules/toidentifier": { 1151 | "version": "1.0.1", 1152 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 1153 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 1154 | "engines": { 1155 | "node": ">=0.6" 1156 | } 1157 | }, 1158 | "node_modules/touch": { 1159 | "version": "3.1.1", 1160 | "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", 1161 | "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", 1162 | "bin": { 1163 | "nodetouch": "bin/nodetouch.js" 1164 | } 1165 | }, 1166 | "node_modules/type-is": { 1167 | "version": "1.6.18", 1168 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 1169 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 1170 | "dependencies": { 1171 | "media-typer": "0.3.0", 1172 | "mime-types": "~2.1.24" 1173 | }, 1174 | "engines": { 1175 | "node": ">= 0.6" 1176 | } 1177 | }, 1178 | "node_modules/undefsafe": { 1179 | "version": "2.0.5", 1180 | "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", 1181 | "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==" 1182 | }, 1183 | "node_modules/unpipe": { 1184 | "version": "1.0.0", 1185 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 1186 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", 1187 | "engines": { 1188 | "node": ">= 0.8" 1189 | } 1190 | }, 1191 | "node_modules/utils-merge": { 1192 | "version": "1.0.1", 1193 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 1194 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", 1195 | "engines": { 1196 | "node": ">= 0.4.0" 1197 | } 1198 | }, 1199 | "node_modules/uuid": { 1200 | "version": "9.0.1", 1201 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", 1202 | "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", 1203 | "funding": [ 1204 | "https://github.com/sponsors/broofa", 1205 | "https://github.com/sponsors/ctavan" 1206 | ], 1207 | "bin": { 1208 | "uuid": "dist/bin/uuid" 1209 | } 1210 | }, 1211 | "node_modules/vary": { 1212 | "version": "1.1.2", 1213 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 1214 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", 1215 | "engines": { 1216 | "node": ">= 0.8" 1217 | } 1218 | } 1219 | } 1220 | } 1221 | --------------------------------------------------------------------------------