├── .gitignore ├── package.json ├── views └── index.ejs ├── LICENSE ├── README.md ├── index.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "main": "index.js", 4 | "scripts": { 5 | "start": "node index.js" 6 | }, 7 | "dependencies": { 8 | "body-parser": "^1.17.2", 9 | "ejs": "^2.5.7", 10 | "express": "^4.15.4", 11 | "form-data": "^2.3.1", 12 | "morgan": "^1.8.2", 13 | "node-fetch": "^1.7.2", 14 | "php-serialize": "^1.2.5", 15 | "sha1": "^1.1.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Purchase Software 6 | 7 | 8 |

Purchase Software

9 | 12 | 13 | 18 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ezekiel Gabrielse 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example Keygen + Paddle integration 2 | The following web app is written in Node.js and shows how to integrate 3 | [Keygen](https://keygen.sh) and [Paddle](https://paddle.com) together 4 | using webhooks. This example app utilizes Paddle subscriptions to showcase 5 | how to create a concurrent/per-seat licensing system, where the customer 6 | is billed for each active machine that is associated with their license. 7 | 8 | The licensing model is implemented by utilizing Paddle's subscription 9 | quantity feature. Each time a new machine is associated with a customer's 10 | license, the subscription's quantity is incremented by 1; likewise, each 11 | time a machine is removed, the subscription's quantity is decremented by 1. 12 | 13 | See our [Electron example application](https://github.com/keygen-sh/example-electron-app) 14 | for ideas on how to integrate this type of licensing into your product. 15 | 16 | > **This example application is not 100% production-ready**, but it should 17 | > get you 90% of the way there. You may need to add additional logging, 18 | > error handling, as well as listening for additional webhook events. 19 | 20 | 🚨 Don't want to host your own webhook server? Check out [our Zapier integration](https://keygen.sh/integrate/zapier/). 21 | 22 | ## Running the app 23 | 24 | First up, configure a few environment variables: 25 | ```bash 26 | # Your Paddle vendor ID (available at https://vendors.paddle.com/account under "Integrations") 27 | export PADDLE_VENDOR_ID="YOUR_PADDLE_VENDOR_ID" 28 | 29 | # Your Paddle API key (available at https://vendors.paddle.com/account under "Integrations") 30 | export PADDLE_API_KEY="YOUR_PADDLE_API_KEY" 31 | 32 | # Your Paddle public key (available at https://vendors.paddle.com/account under "Public Key") 33 | export PADDLE_PUBLIC_KEY=$(printf %b \ 34 | '-----BEGIN PUBLIC KEY-----\n' \ 35 | 'zdL8BgMFM7p7+FGEGuH1I0KBaMcB/RZZSUu4yTBMu0pJw2EWzr3CrOOiXQI3+6bA\n' \ 36 | # … 37 | 'efK41Ml6OwZB3tchqGmpuAsCEwEAaQ==\n' \ 38 | '-----END PUBLIC KEY-----') 39 | 40 | # Paddle plan ID to subscribe customers to 41 | export PADDLE_PLAN_ID="YOUR_PADDLE_PLAN_ID" 42 | 43 | # Keygen product token (don't share this!) 44 | export KEYGEN_PRODUCT_TOKEN="YOUR_KEYGEN_PRODUCT_TOKEN" 45 | 46 | # Your Keygen account ID 47 | export KEYGEN_ACCOUNT_ID="YOUR_KEYGEN_ACCOUNT_ID" 48 | 49 | # The Keygen policy to use when creating licenses for new customers 50 | # after they successfully subscribe to a plan 51 | export KEYGEN_POLICY_ID="YOUR_KEYGEN_POLICY_ID" 52 | ``` 53 | 54 | You can either run each line above within your terminal session before 55 | starting the app, or you can add the above contents to your `~/.bashrc` 56 | file and then run `source ~/.bashrc` after saving the file. 57 | 58 | Next, install dependencies with [`yarn`](https://yarnpkg.comg): 59 | ``` 60 | yarn 61 | ``` 62 | 63 | Then start the app: 64 | ``` 65 | yarn start 66 | ``` 67 | 68 | ## Testing webhooks locally 69 | 70 | For local development, create an [`ngrok`](https://ngrok.com) tunnel: 71 | ``` 72 | ngrok http 8080 73 | ``` 74 | 75 | Next up, add the secure `ngrok` URL to your Paddle and Keygen accounts to 76 | listen for webhooks. 77 | 78 | 1. **Paddle:** add `https://{YOUR_NGROK_URL}/paddle-webhooks` to https://vendors.paddle.com/account under 79 | "Alerts", subscribe to `subscription_created`, `subscription_updated`, 80 | and `subscription_cancelled` 81 | 1. **Keygen:** add `https://{YOUR_NGROK_URL}/keygen-webhooks` to https://app.keygen.sh/webhook-endpoints 82 | 83 | ## Testing the integration 84 | 85 | Visit the following url: http://localhost:8080 and fill out the Paddle 86 | checkout form to subscribe. 87 | 88 | ## Questions? 89 | 90 | Reach out at [support@keygen.sh](mailto:support@keygen.sh) if you have any 91 | questions or concerns! 92 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Be sure to add these ENV variables! 2 | const { 3 | PADDLE_PUBLIC_KEY, 4 | PADDLE_VENDOR_ID, 5 | PADDLE_API_KEY, 6 | PADDLE_PLAN_ID, 7 | KEYGEN_PRODUCT_TOKEN, 8 | KEYGEN_ACCOUNT_ID, 9 | KEYGEN_POLICY_ID, 10 | PORT = 8080 11 | } = process.env 12 | 13 | const { serialize } = require('php-serialize') 14 | const sha1 = require('sha1') 15 | const crypto = require('crypto') 16 | const fetch = require('node-fetch') 17 | const FormData = require('form-data') 18 | const express = require('express') 19 | const bodyParser = require('body-parser') 20 | const morgan = require('morgan') 21 | const app = express() 22 | 23 | app.use(bodyParser.json({ type: 'application/vnd.api+json' })) 24 | app.use(bodyParser.json({ type: 'application/json' })) 25 | app.use(bodyParser.urlencoded({ extended: true })) 26 | app.use(morgan('combined')) 27 | 28 | app.set('view engine', 'ejs') 29 | 30 | // Verify Paddle webhook data using our public key 31 | const verifyPaddleWebhook = data => { 32 | const signature = data.p_signature 33 | const keys = Object.keys(data).filter(k => k !== 'p_signature').sort() 34 | const sorted = {} 35 | 36 | keys.forEach(key => { 37 | sorted[key] = data[key] 38 | }) 39 | 40 | const serialized = serialize(sorted) 41 | try { 42 | const verifier = crypto.createVerify('sha1') 43 | verifier.write(serialized) 44 | verifier.end() 45 | 46 | return verifier.verify(PADDLE_PUBLIC_KEY, signature, 'base64') 47 | } catch (err) { 48 | return false 49 | } 50 | } 51 | 52 | app.post('/paddle-webhooks', async (req, res) => { 53 | const { body: paddleEvent } = req 54 | 55 | if (!verifyPaddleWebhook(paddleEvent)) { 56 | return res.status(400).send('Bad signature or public key') // Webhook was not sent from Paddle 57 | } 58 | 59 | switch (paddleEvent.alert_name) { 60 | case 'subscription_created': { 61 | // 1. Create a license for the new Paddle customer after their subscription 62 | // has successfully been created. 63 | const keygenLicense = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses`, { 64 | method: 'POST', 65 | headers: { 66 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 67 | 'Content-Type': 'application/vnd.api+json', 68 | 'Accept': 'application/vnd.api+json' 69 | }, 70 | body: JSON.stringify({ 71 | data: { 72 | type: 'licenses', 73 | attributes: { 74 | // Since Paddle doesn't allow us to store metadata on their resources, 75 | // we're going to hash the email and checkout_id together to create a 76 | // reproducible license key. This will make it easier to lookup later 77 | // on if the customer ever cancels their subscription. 78 | key: sha1(`${paddleEvent.email}-${paddleEvent.checkout_id}`).slice(0, 20).split(/(.{4})/).filter(Boolean).join('-'), 79 | metadata: { 80 | paddleCustomerEmail: paddleEvent.email, 81 | paddleSubscriptionId: paddleEvent.subscription_id, 82 | paddlePlanId: paddleEvent.subscription_plan_id, 83 | paddleCheckoutId: paddleEvent.checkout_id 84 | } 85 | }, 86 | relationships: { 87 | policy: { 88 | data: { type: 'policies', id: KEYGEN_POLICY_ID } 89 | } 90 | } 91 | } 92 | }) 93 | }) 94 | 95 | const { data, errors } = await keygenLicense.json() 96 | if (errors) { 97 | res.sendStatus(500) 98 | 99 | // If you receive an error here, then you may want to handle the fact the customer 100 | // may have been charged for a license that they didn't receive e.g. easiest way 101 | // would be to create it manually, or refund their subscription charge. 102 | throw new Error(errors.map(e => e.detail).toString()) 103 | } 104 | 105 | // 2. All is good! License was successfully created for the new Paddle customer. 106 | // Next up would be for us to email the license key to our customer's email 107 | // using `paddleEvent.email` or something similar. 108 | 109 | // Let Paddle know the event was received successfully. 110 | res.sendStatus(200) 111 | break 112 | } 113 | case 'subscription_updated': { 114 | // Calculate the license key from the customer's email and the subscription checkout 115 | // id. See the subscription_created webhook handler above for more info. 116 | const key = sha1(`${paddleEvent.email}-${paddleEvent.checkout_id}`).slice(0, 20).split(/(.{4})/).filter(Boolean).join('-') 117 | 118 | // Retreive the customer's license whenever their subscription is updated. 119 | const keygenLicense = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses/${key}`, { 120 | method: 'GET', 121 | headers: { 122 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 123 | 'Accept': 'application/vnd.api+json' 124 | } 125 | }) 126 | 127 | const { data: license, errors } = await keygenLicense.json() 128 | if (errors) { 129 | return res.sendStatus(200) // License doesn't exist for this customer 130 | } 131 | 132 | switch (paddleEvent.status) { 133 | case 'past_due': { 134 | if (license.attributes.suspended) { // Skip if the license is already suspended 135 | break 136 | } 137 | 138 | // Suspend the customer's license whenever their subscription is past due. 139 | const keygenLicense = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses/${key}/actions/suspend`, { 140 | method: 'POST', 141 | headers: { 142 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 143 | 'Accept': 'application/vnd.api+json' 144 | } 145 | }) 146 | 147 | const { errors } = await keygenLicense.json() 148 | if (errors) { 149 | res.sendStatus(500) 150 | 151 | // If you receive an error here, then you may want to handle the fact the customer 152 | // has a subscription that is past due, but still has a valid license. 153 | throw new Error(errors.map(e => e.detail).toString()) 154 | } 155 | 156 | break 157 | } 158 | case 'active': { 159 | if (!license.attributes.suspended) { // Skip if the license isn't suspended 160 | break 161 | } 162 | 163 | // Reinstate the customer's suspended license whenever their subscription 164 | // moves out of being past due. 165 | const keygenLicense = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses/${key}/actions/reinstate`, { 166 | method: 'POST', 167 | headers: { 168 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 169 | 'Accept': 'application/vnd.api+json' 170 | } 171 | }) 172 | 173 | const { errors } = await keygenLicense.json() 174 | if (errors) { 175 | res.sendStatus(500) 176 | 177 | // If you receive an error here, then you may want to handle the fact the customer 178 | // has potentially renewed their subscription, but still has a suspended license. 179 | throw new Error(errors.map(e => e.detail).toString()) 180 | } 181 | 182 | break 183 | } 184 | } 185 | 186 | res.sendStatus(200) 187 | break 188 | } 189 | case 'subscription_cancelled': { 190 | // Calculate the license key from the customer's email and the subscription checkout 191 | // id. See the subscription_created webhook handler above for more info. 192 | const key = sha1(`${paddleEvent.email}-${paddleEvent.checkout_id}`).slice(0, 20).split(/(.{4})/).filter(Boolean).join('-') 193 | 194 | // Revoke the customer's license whenever they cancel their subscription. 195 | const keygenLicense = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses/${key}/actions/revoke`, { 196 | method: 'DELETE', 197 | headers: { 198 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 199 | 'Accept': 'application/vnd.api+json' 200 | } 201 | }) 202 | 203 | if (keygenLicense.status !== 204) { 204 | const { errors } = await keygenLicense.json() 205 | if (errors) { 206 | res.sendStatus(500) 207 | 208 | // If you receive an error here, then you may want to handle the fact the customer 209 | // has potentially canceled their subscription, but still has a valid license. 210 | throw new Error(errors.map(e => e.detail).toString()) 211 | } 212 | } 213 | 214 | res.sendStatus(200) 215 | break 216 | } 217 | default: { 218 | res.sendStatus(200) 219 | } 220 | } 221 | }) 222 | 223 | app.post('/keygen-webhooks', async (req, res) => { 224 | const { data: { id: keygenEventId } } = req.body 225 | 226 | // Fetch the webhook to validate it and get its most up-to-date state 227 | const keygenWebhook = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/webhook-events/${keygenEventId}`, { 228 | method: 'GET', 229 | headers: { 230 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 231 | 'Accept': 'application/vnd.api+json' 232 | } 233 | }) 234 | 235 | const { data: keygenEvent, errors } = await keygenWebhook.json() 236 | if (errors) { 237 | return res.sendStatus(200) // Event does not exist (wasn't sent from Keygen) 238 | } 239 | 240 | switch (keygenEvent.attributes.event) { 241 | // 3. Respond to machine creation and deletion events within your Keygen account. Here, 242 | // we'll keep our customer's subscription quantity up to date with the number of 243 | // machines they have for their license. 244 | case 'machine.created': 245 | case 'machine.deleted': { 246 | const { data: keygenMachine } = JSON.parse(keygenEvent.attributes.payload) 247 | 248 | // 4. Request the customer's license so that we can look up the correct 249 | // Paddle subscription. 250 | const keygenLicense = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses/${keygenMachine.relationships.license.data.id}`, { 251 | method: 'GET', 252 | headers: { 253 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 254 | 'Accept': 'application/vnd.api+json' 255 | } 256 | }) 257 | 258 | const { data: license, errors: errs1 } = await keygenLicense.json() 259 | if (errs1) { 260 | res.sendStatus(500) 261 | 262 | throw new Error(errs1.map(e => e.detail).toString()) 263 | } 264 | 265 | // 5. Request the customer's machines so that we can update their subscription 266 | // to reflect the current machine count. 267 | const keygenMachines = await fetch(`https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT_ID}/licenses/${license.id}/machines?page[size]=100&page[number]=1`, { 268 | method: 'GET', 269 | headers: { 270 | 'Authorization': `Bearer ${KEYGEN_PRODUCT_TOKEN}`, 271 | 'Accept': 'application/vnd.api+json' 272 | } 273 | }) 274 | 275 | const { data: machines, links, errors: errs2 } = await keygenMachines.json() 276 | if (errs2) { 277 | res.sendStatus(500) 278 | 279 | throw new Error(errs2.map(e => e.detail).toString()) 280 | } 281 | 282 | // TODO(ezekg) Handle pagination traversal, where a customer may have more 283 | // than 100 machines associated with a single license. 284 | let machineCount = machines.length 285 | 286 | // 6. Update the customer's Paddle subscription quantity to reflect their 287 | // license's current machine count. 288 | const { paddleSubscriptionId } = license.attributes.metadata 289 | const formData = new FormData() 290 | const params = { 291 | subscription_id: paddleSubscriptionId, 292 | quantity: machineCount, 293 | vendor_id: PADDLE_VENDOR_ID, 294 | vendor_auth_code: PADDLE_API_KEY 295 | } 296 | 297 | for (let name in params) { 298 | formData.append(name, params[name]) 299 | } 300 | 301 | const paddleUpdate = await fetch(`https://vendors.paddle.com/api/2.0/subscription/users/update`, { 302 | method: 'POST', 303 | body: formData 304 | }) 305 | 306 | const { success, error } = await paddleUpdate.json() 307 | if (error) { 308 | res.sendStatus(500) 309 | 310 | throw new Error(error.message) 311 | } 312 | 313 | // All is good! Our Paddle customer's subscription quantity has been 314 | // updated to reflect their up-to-date machine count. 315 | res.sendStatus(200) 316 | break 317 | } 318 | default: { 319 | // For events we don't care about, let Keygen know all is good. 320 | res.sendStatus(200) 321 | } 322 | } 323 | }) 324 | 325 | app.get('/', async (req, res) => { 326 | res.render('index', { 327 | PADDLE_VENDOR_ID, 328 | PADDLE_PLAN_ID 329 | }) 330 | }) 331 | 332 | process.on('unhandledRejection', err => { 333 | console.error(`Unhandled rejection: ${err}`, err.stack) 334 | }) 335 | 336 | const server = app.listen(PORT, 'localhost', () => { 337 | const { address, port } = server.address() 338 | 339 | console.log(`Listening at http://${address}:${port}`) 340 | }) 341 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | accepts@~1.3.3: 6 | version "1.3.4" 7 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f" 8 | dependencies: 9 | mime-types "~2.1.16" 10 | negotiator "0.6.1" 11 | 12 | array-flatten@1.1.1: 13 | version "1.1.1" 14 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 15 | 16 | asynckit@^0.4.0: 17 | version "0.4.0" 18 | resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" 19 | 20 | basic-auth@~1.1.0: 21 | version "1.1.0" 22 | resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-1.1.0.tgz#45221ee429f7ee1e5035be3f51533f1cdfd29884" 23 | 24 | body-parser@^1.17.2: 25 | version "1.17.2" 26 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.17.2.tgz#f8892abc8f9e627d42aedafbca66bf5ab99104ee" 27 | dependencies: 28 | bytes "2.4.0" 29 | content-type "~1.0.2" 30 | debug "2.6.7" 31 | depd "~1.1.0" 32 | http-errors "~1.6.1" 33 | iconv-lite "0.4.15" 34 | on-finished "~2.3.0" 35 | qs "6.4.0" 36 | raw-body "~2.2.0" 37 | type-is "~1.6.15" 38 | 39 | bytes@2.4.0: 40 | version "2.4.0" 41 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.4.0.tgz#7d97196f9d5baf7f6935e25985549edd2a6c2339" 42 | 43 | "charenc@>= 0.0.1": 44 | version "0.0.2" 45 | resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" 46 | 47 | combined-stream@^1.0.5: 48 | version "1.0.5" 49 | resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" 50 | dependencies: 51 | delayed-stream "~1.0.0" 52 | 53 | content-disposition@0.5.2: 54 | version "0.5.2" 55 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" 56 | 57 | content-type@~1.0.2: 58 | version "1.0.2" 59 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" 60 | 61 | cookie-signature@1.0.6: 62 | version "1.0.6" 63 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 64 | 65 | cookie@0.3.1: 66 | version "0.3.1" 67 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" 68 | 69 | "crypt@>= 0.0.1": 70 | version "0.0.2" 71 | resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" 72 | 73 | debug@2.6.7: 74 | version "2.6.7" 75 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.7.tgz#92bad1f6d05bbb6bba22cca88bcd0ec894c2861e" 76 | dependencies: 77 | ms "2.0.0" 78 | 79 | debug@2.6.8: 80 | version "2.6.8" 81 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" 82 | dependencies: 83 | ms "2.0.0" 84 | 85 | delayed-stream@~1.0.0: 86 | version "1.0.0" 87 | resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" 88 | 89 | depd@1.1.1, depd@~1.1.0, depd@~1.1.1: 90 | version "1.1.1" 91 | resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" 92 | 93 | destroy@~1.0.4: 94 | version "1.0.4" 95 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" 96 | 97 | ee-first@1.1.1: 98 | version "1.1.1" 99 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 100 | 101 | ejs@^2.5.7: 102 | version "2.5.7" 103 | resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.7.tgz#cc872c168880ae3c7189762fd5ffc00896c9518a" 104 | 105 | encodeurl@~1.0.1: 106 | version "1.0.1" 107 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" 108 | 109 | encoding@^0.1.11: 110 | version "0.1.12" 111 | resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" 112 | dependencies: 113 | iconv-lite "~0.4.13" 114 | 115 | escape-html@~1.0.3: 116 | version "1.0.3" 117 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 118 | 119 | etag@~1.8.0: 120 | version "1.8.0" 121 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051" 122 | 123 | express@^4.15.4: 124 | version "4.15.4" 125 | resolved "https://registry.yarnpkg.com/express/-/express-4.15.4.tgz#032e2253489cf8fce02666beca3d11ed7a2daed1" 126 | dependencies: 127 | accepts "~1.3.3" 128 | array-flatten "1.1.1" 129 | content-disposition "0.5.2" 130 | content-type "~1.0.2" 131 | cookie "0.3.1" 132 | cookie-signature "1.0.6" 133 | debug "2.6.8" 134 | depd "~1.1.1" 135 | encodeurl "~1.0.1" 136 | escape-html "~1.0.3" 137 | etag "~1.8.0" 138 | finalhandler "~1.0.4" 139 | fresh "0.5.0" 140 | merge-descriptors "1.0.1" 141 | methods "~1.1.2" 142 | on-finished "~2.3.0" 143 | parseurl "~1.3.1" 144 | path-to-regexp "0.1.7" 145 | proxy-addr "~1.1.5" 146 | qs "6.5.0" 147 | range-parser "~1.2.0" 148 | send "0.15.4" 149 | serve-static "1.12.4" 150 | setprototypeof "1.0.3" 151 | statuses "~1.3.1" 152 | type-is "~1.6.15" 153 | utils-merge "1.0.0" 154 | vary "~1.1.1" 155 | 156 | finalhandler@~1.0.4: 157 | version "1.0.4" 158 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.4.tgz#18574f2e7c4b98b8ae3b230c21f201f31bdb3fb7" 159 | dependencies: 160 | debug "2.6.8" 161 | encodeurl "~1.0.1" 162 | escape-html "~1.0.3" 163 | on-finished "~2.3.0" 164 | parseurl "~1.3.1" 165 | statuses "~1.3.1" 166 | unpipe "~1.0.0" 167 | 168 | form-data@^2.3.1: 169 | version "2.3.1" 170 | resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf" 171 | dependencies: 172 | asynckit "^0.4.0" 173 | combined-stream "^1.0.5" 174 | mime-types "^2.1.12" 175 | 176 | forwarded@~0.1.0: 177 | version "0.1.0" 178 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" 179 | 180 | fresh@0.5.0: 181 | version "0.5.0" 182 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e" 183 | 184 | http-errors@~1.6.1, http-errors@~1.6.2: 185 | version "1.6.2" 186 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" 187 | dependencies: 188 | depd "1.1.1" 189 | inherits "2.0.3" 190 | setprototypeof "1.0.3" 191 | statuses ">= 1.3.1 < 2" 192 | 193 | iconv-lite@0.4.15: 194 | version "0.4.15" 195 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb" 196 | 197 | iconv-lite@~0.4.13: 198 | version "0.4.18" 199 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2" 200 | 201 | inherits@2.0.3: 202 | version "2.0.3" 203 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 204 | 205 | ipaddr.js@1.4.0: 206 | version "1.4.0" 207 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.4.0.tgz#296aca878a821816e5b85d0a285a99bcff4582f0" 208 | 209 | is-stream@^1.0.1: 210 | version "1.1.0" 211 | resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" 212 | 213 | media-typer@0.3.0: 214 | version "0.3.0" 215 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 216 | 217 | merge-descriptors@1.0.1: 218 | version "1.0.1" 219 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 220 | 221 | methods@~1.1.2: 222 | version "1.1.2" 223 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 224 | 225 | mime-db@~1.29.0: 226 | version "1.29.0" 227 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.29.0.tgz#48d26d235589651704ac5916ca06001914266878" 228 | 229 | mime-db@~1.30.0: 230 | version "1.30.0" 231 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" 232 | 233 | mime-types@^2.1.12: 234 | version "2.1.17" 235 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a" 236 | dependencies: 237 | mime-db "~1.30.0" 238 | 239 | mime-types@~2.1.15, mime-types@~2.1.16: 240 | version "2.1.16" 241 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.16.tgz#2b858a52e5ecd516db897ac2be87487830698e23" 242 | dependencies: 243 | mime-db "~1.29.0" 244 | 245 | mime@1.3.4: 246 | version "1.3.4" 247 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" 248 | 249 | morgan@^1.8.2: 250 | version "1.8.2" 251 | resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.8.2.tgz#784ac7734e4a453a9c6e6e8680a9329275c8b687" 252 | dependencies: 253 | basic-auth "~1.1.0" 254 | debug "2.6.8" 255 | depd "~1.1.0" 256 | on-finished "~2.3.0" 257 | on-headers "~1.0.1" 258 | 259 | ms@2.0.0: 260 | version "2.0.0" 261 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 262 | 263 | negotiator@0.6.1: 264 | version "0.6.1" 265 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" 266 | 267 | node-fetch@^1.7.2: 268 | version "1.7.2" 269 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.2.tgz#c54e9aac57e432875233525f3c891c4159ffefd7" 270 | dependencies: 271 | encoding "^0.1.11" 272 | is-stream "^1.0.1" 273 | 274 | on-finished@~2.3.0: 275 | version "2.3.0" 276 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" 277 | dependencies: 278 | ee-first "1.1.1" 279 | 280 | on-headers@~1.0.1: 281 | version "1.0.1" 282 | resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" 283 | 284 | parseurl@~1.3.1: 285 | version "1.3.1" 286 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56" 287 | 288 | path-to-regexp@0.1.7: 289 | version "0.1.7" 290 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 291 | 292 | php-serialize@^1.2.5: 293 | version "1.2.5" 294 | resolved "https://registry.yarnpkg.com/php-serialize/-/php-serialize-1.2.5.tgz#2ebf081d43da97bfe762526459ee4e2c97099f62" 295 | 296 | proxy-addr@~1.1.5: 297 | version "1.1.5" 298 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918" 299 | dependencies: 300 | forwarded "~0.1.0" 301 | ipaddr.js "1.4.0" 302 | 303 | qs@6.4.0: 304 | version "6.4.0" 305 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" 306 | 307 | qs@6.5.0: 308 | version "6.5.0" 309 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49" 310 | 311 | range-parser@~1.2.0: 312 | version "1.2.0" 313 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" 314 | 315 | raw-body@~2.2.0: 316 | version "2.2.0" 317 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" 318 | dependencies: 319 | bytes "2.4.0" 320 | iconv-lite "0.4.15" 321 | unpipe "1.0.0" 322 | 323 | send@0.15.4: 324 | version "0.15.4" 325 | resolved "https://registry.yarnpkg.com/send/-/send-0.15.4.tgz#985faa3e284b0273c793364a35c6737bd93905b9" 326 | dependencies: 327 | debug "2.6.8" 328 | depd "~1.1.1" 329 | destroy "~1.0.4" 330 | encodeurl "~1.0.1" 331 | escape-html "~1.0.3" 332 | etag "~1.8.0" 333 | fresh "0.5.0" 334 | http-errors "~1.6.2" 335 | mime "1.3.4" 336 | ms "2.0.0" 337 | on-finished "~2.3.0" 338 | range-parser "~1.2.0" 339 | statuses "~1.3.1" 340 | 341 | serve-static@1.12.4: 342 | version "1.12.4" 343 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.4.tgz#9b6aa98eeb7253c4eedc4c1f6fdbca609901a961" 344 | dependencies: 345 | encodeurl "~1.0.1" 346 | escape-html "~1.0.3" 347 | parseurl "~1.3.1" 348 | send "0.15.4" 349 | 350 | setprototypeof@1.0.3: 351 | version "1.0.3" 352 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" 353 | 354 | sha1@^1.1.1: 355 | version "1.1.1" 356 | resolved "https://registry.yarnpkg.com/sha1/-/sha1-1.1.1.tgz#addaa7a93168f393f19eb2b15091618e2700f848" 357 | dependencies: 358 | charenc ">= 0.0.1" 359 | crypt ">= 0.0.1" 360 | 361 | "statuses@>= 1.3.1 < 2", statuses@~1.3.1: 362 | version "1.3.1" 363 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" 364 | 365 | type-is@~1.6.15: 366 | version "1.6.15" 367 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" 368 | dependencies: 369 | media-typer "0.3.0" 370 | mime-types "~2.1.15" 371 | 372 | unpipe@1.0.0, unpipe@~1.0.0: 373 | version "1.0.0" 374 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 375 | 376 | utils-merge@1.0.0: 377 | version "1.0.0" 378 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" 379 | 380 | vary@~1.1.1: 381 | version "1.1.1" 382 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37" 383 | --------------------------------------------------------------------------------