├── .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 |
--------------------------------------------------------------------------------