├── .github ├── CODEOWNERS └── dependabot.yml ├── .gitignore ├── README.md ├── assets ├── configure-bot.png ├── connect-account.png ├── connected.png ├── consent-dialog.png ├── create-app.png ├── glitch-share-url.png ├── glitch.png ├── oauth-config.png ├── oauth-url.png ├── register.png ├── verification-setup.png └── verify-endpoint.png ├── package-lock.json ├── package.json ├── renovate.json └── src ├── config.js ├── discord.js ├── register.js ├── server.js └── storage.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @discord/devrel 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "npm" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.json 3 | .DS_Store 4 | .env 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linked Role example app 2 | 3 | This repository contains the documentation and example for a linked role bot. 4 | 5 | > ❇️ A version of this code is also hosted [on Glitch 🎏](https://glitch.com/edit/#!/linked-role-discord-bot) 6 | 7 | ## Project structure 8 | All of the files for the project are on the left-hand side. Here's a quick glimpse at the structure: 9 | 10 | ``` 11 | ├── assets -> Images used in this tutorial 12 | ├── src 13 | │ ├── config.js -> Parsing of local configuration 14 | │ ├── discord.js -> Discord specific auth & API wrapper 15 | │ ├── register.js -> Tool to register the metadata schema 16 | │ ├── server.js -> Main entry point for the application 17 | │ ├── storage.js -> Provider for storing OAuth2 tokens 18 | ├── .env -> your credentials and IDs 19 | ├── .gitignore 20 | ├── package.json 21 | └── README.md 22 | ``` 23 | 24 | ## Running app locally 25 | 26 | Before you start, you'll need to [create a Discord app](https://discord.com/developers/applications) with the `bot` scope 27 | 28 | Configuring the app is covered in detail in the [tutorial](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles). 29 | 30 | ### Setup project 31 | 32 | First clone the project: 33 | ``` 34 | git clone https://github.com/discord/linked-roles-sample.git 35 | ``` 36 | 37 | Then navigate to its directory and install dependencies: 38 | ``` 39 | cd linked-roles-sample 40 | npm install 41 | ``` 42 | 43 | ### Get app credentials 44 | 45 | Fetch the credentials from your app's settings and add them to a `.env` file. You'll need your bot token (`DISCORD_TOKEN`), client ID (`DISCORD_CLIENT_ID`), client secret (`DISCORD_CLIENT_SECRET`). You'll also need a redirect URI (`DISCORD_REDIRECT_URI`) and a randomly generated UUID (`COOKIE_SECRET`), which are both explained below: 46 | 47 | ``` 48 | DISCORD_CLIENT_ID: 49 | DISCORD_CLIENT_SECRET: 50 | DISCORD_TOKEN: 51 | DISCORD_REDIRECT_URI: https:///discord-oauth-callback 52 | COOKIE_SECRET: 53 | ``` 54 | 55 | For the UUID (`COOKIE_SECRET`), you can run the following commands: 56 | 57 | ``` 58 | $ node 59 | crypto.randomUUID() 60 | ``` 61 | 62 | Copy and paste the value into your `.env` file. 63 | 64 | Fetching credentials is covered in detail in the [linked roles tutorial](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles). 65 | 66 | ### Running your app 67 | 68 | After your credentials are added, you can run your app: 69 | 70 | ``` 71 | $ node server.js 72 | ``` 73 | 74 | And, just once, you need to register you connection metadata schema. In a new window, run: 75 | 76 | ``` 77 | $ node src/register.js 78 | ``` 79 | 80 | ### Set up interactivity 81 | 82 | The project needs a public endpoint where Discord can send requests. To develop and test locally, you can use something like [`ngrok`](https://ngrok.com/) to tunnel HTTP traffic. 83 | 84 | Install ngrok if you haven't already, then start listening on port `3000`: 85 | 86 | ``` 87 | $ ngrok http 3000 88 | ``` 89 | 90 | You should see your connection open: 91 | 92 | ``` 93 | Tunnel Status online 94 | Version 2.0/2.0 95 | Web Interface http://127.0.0.1:4040 96 | Forwarding http://1234-someurl.ngrok.io -> localhost:3000 97 | Forwarding https://1234-someurl.ngrok.io -> localhost:3000 98 | 99 | Connections ttl opn rt1 rt5 p50 p90 100 | 0 0 0.00 0.00 0.00 0.00 101 | ``` 102 | 103 | Copy the forwarding address that starts with `https`, in this case `https://1234-someurl.ngrok.io`, then go to your [app's settings](https://discord.com/developers/applications). 104 | 105 | On the **General Information** tab, there will be an **Linked Roles Verification URL**. Paste your ngrok address there, and append `/linked-role` (`https://1234-someurl.ngrok.io/linked-role` in the example). 106 | 107 | You should also paste your ngrok address into the `DISCORD_REDIRECT_URI` variable in your `.env` file, with `/discord-oauth-callback` appended (`https://1234-someurl.ngrok.io/discord-oauth-callback` in the example). Then go to the **General** tab under **OAuth2** in your [app's settings](https://discord.com/developers/applications), and add that same address to the list of **Redirects**. 108 | 109 | Click **Save Changes** and restart your app. 110 | 111 | ## Other resources 112 | - Read **[the tutorial](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles)** for in-depth information. 113 | - Browse https://github.com/JustinBeckwith/fitbit-discord-bot/ for a more in-depth example using the Fitbit API 114 | - Join the **[Discord Developers server](https://discord.gg/discord-developers)** to ask questions about the API, attend events hosted by the Discord API team, and interact with other devs. 115 | -------------------------------------------------------------------------------- /assets/configure-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/configure-bot.png -------------------------------------------------------------------------------- /assets/connect-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/connect-account.png -------------------------------------------------------------------------------- /assets/connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/connected.png -------------------------------------------------------------------------------- /assets/consent-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/consent-dialog.png -------------------------------------------------------------------------------- /assets/create-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/create-app.png -------------------------------------------------------------------------------- /assets/glitch-share-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/glitch-share-url.png -------------------------------------------------------------------------------- /assets/glitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/glitch.png -------------------------------------------------------------------------------- /assets/oauth-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/oauth-config.png -------------------------------------------------------------------------------- /assets/oauth-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/oauth-url.png -------------------------------------------------------------------------------- /assets/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/register.png -------------------------------------------------------------------------------- /assets/verification-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/verification-setup.png -------------------------------------------------------------------------------- /assets/verify-endpoint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/linked-roles-sample/f031c34d9970882cae0bc5c9ddf7542ae6d79982/assets/verify-endpoint.png -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linked-role-bot", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "linked-role-bot", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "cookie-parser": "^1.4.7", 13 | "dotenv": "^16.5.0", 14 | "express": "^4.21.2" 15 | }, 16 | "engines": { 17 | "node": ">=18" 18 | } 19 | }, 20 | "node_modules/accepts": { 21 | "version": "1.3.8", 22 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 23 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 24 | "dependencies": { 25 | "mime-types": "~2.1.34", 26 | "negotiator": "0.6.3" 27 | }, 28 | "engines": { 29 | "node": ">= 0.6" 30 | } 31 | }, 32 | "node_modules/array-flatten": { 33 | "version": "1.1.1", 34 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 35 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 36 | }, 37 | "node_modules/body-parser": { 38 | "version": "1.20.3", 39 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", 40 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 41 | "dependencies": { 42 | "bytes": "3.1.2", 43 | "content-type": "~1.0.5", 44 | "debug": "2.6.9", 45 | "depd": "2.0.0", 46 | "destroy": "1.2.0", 47 | "http-errors": "2.0.0", 48 | "iconv-lite": "0.4.24", 49 | "on-finished": "2.4.1", 50 | "qs": "6.13.0", 51 | "raw-body": "2.5.2", 52 | "type-is": "~1.6.18", 53 | "unpipe": "1.0.0" 54 | }, 55 | "engines": { 56 | "node": ">= 0.8", 57 | "npm": "1.2.8000 || >= 1.4.16" 58 | } 59 | }, 60 | "node_modules/bytes": { 61 | "version": "3.1.2", 62 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 63 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", 64 | "engines": { 65 | "node": ">= 0.8" 66 | } 67 | }, 68 | "node_modules/call-bind": { 69 | "version": "1.0.7", 70 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", 71 | "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", 72 | "dependencies": { 73 | "es-define-property": "^1.0.0", 74 | "es-errors": "^1.3.0", 75 | "function-bind": "^1.1.2", 76 | "get-intrinsic": "^1.2.4", 77 | "set-function-length": "^1.2.1" 78 | }, 79 | "engines": { 80 | "node": ">= 0.4" 81 | }, 82 | "funding": { 83 | "url": "https://github.com/sponsors/ljharb" 84 | } 85 | }, 86 | "node_modules/content-disposition": { 87 | "version": "0.5.4", 88 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 89 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 90 | "dependencies": { 91 | "safe-buffer": "5.2.1" 92 | }, 93 | "engines": { 94 | "node": ">= 0.6" 95 | } 96 | }, 97 | "node_modules/content-type": { 98 | "version": "1.0.5", 99 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 100 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", 101 | "engines": { 102 | "node": ">= 0.6" 103 | } 104 | }, 105 | "node_modules/cookie": { 106 | "version": "0.7.2", 107 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 108 | "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 109 | "engines": { 110 | "node": ">= 0.6" 111 | } 112 | }, 113 | "node_modules/cookie-parser": { 114 | "version": "1.4.7", 115 | "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", 116 | "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", 117 | "dependencies": { 118 | "cookie": "0.7.2", 119 | "cookie-signature": "1.0.6" 120 | }, 121 | "engines": { 122 | "node": ">= 0.8.0" 123 | } 124 | }, 125 | "node_modules/cookie-signature": { 126 | "version": "1.0.6", 127 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 128 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 129 | }, 130 | "node_modules/debug": { 131 | "version": "2.6.9", 132 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 133 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 134 | "dependencies": { 135 | "ms": "2.0.0" 136 | } 137 | }, 138 | "node_modules/define-data-property": { 139 | "version": "1.1.4", 140 | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 141 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 142 | "dependencies": { 143 | "es-define-property": "^1.0.0", 144 | "es-errors": "^1.3.0", 145 | "gopd": "^1.0.1" 146 | }, 147 | "engines": { 148 | "node": ">= 0.4" 149 | }, 150 | "funding": { 151 | "url": "https://github.com/sponsors/ljharb" 152 | } 153 | }, 154 | "node_modules/depd": { 155 | "version": "2.0.0", 156 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 157 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 158 | "engines": { 159 | "node": ">= 0.8" 160 | } 161 | }, 162 | "node_modules/destroy": { 163 | "version": "1.2.0", 164 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 165 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", 166 | "engines": { 167 | "node": ">= 0.8", 168 | "npm": "1.2.8000 || >= 1.4.16" 169 | } 170 | }, 171 | "node_modules/dotenv": { 172 | "version": "16.5.0", 173 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 174 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 175 | "license": "BSD-2-Clause", 176 | "engines": { 177 | "node": ">=12" 178 | }, 179 | "funding": { 180 | "url": "https://dotenvx.com" 181 | } 182 | }, 183 | "node_modules/ee-first": { 184 | "version": "1.1.1", 185 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 186 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 187 | }, 188 | "node_modules/encodeurl": { 189 | "version": "2.0.0", 190 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 191 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", 192 | "engines": { 193 | "node": ">= 0.8" 194 | } 195 | }, 196 | "node_modules/es-define-property": { 197 | "version": "1.0.0", 198 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", 199 | "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", 200 | "dependencies": { 201 | "get-intrinsic": "^1.2.4" 202 | }, 203 | "engines": { 204 | "node": ">= 0.4" 205 | } 206 | }, 207 | "node_modules/es-errors": { 208 | "version": "1.3.0", 209 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 210 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 211 | "engines": { 212 | "node": ">= 0.4" 213 | } 214 | }, 215 | "node_modules/escape-html": { 216 | "version": "1.0.3", 217 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 218 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 219 | }, 220 | "node_modules/etag": { 221 | "version": "1.8.1", 222 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 223 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", 224 | "engines": { 225 | "node": ">= 0.6" 226 | } 227 | }, 228 | "node_modules/express": { 229 | "version": "4.21.2", 230 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", 231 | "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", 232 | "dependencies": { 233 | "accepts": "~1.3.8", 234 | "array-flatten": "1.1.1", 235 | "body-parser": "1.20.3", 236 | "content-disposition": "0.5.4", 237 | "content-type": "~1.0.4", 238 | "cookie": "0.7.1", 239 | "cookie-signature": "1.0.6", 240 | "debug": "2.6.9", 241 | "depd": "2.0.0", 242 | "encodeurl": "~2.0.0", 243 | "escape-html": "~1.0.3", 244 | "etag": "~1.8.1", 245 | "finalhandler": "1.3.1", 246 | "fresh": "0.5.2", 247 | "http-errors": "2.0.0", 248 | "merge-descriptors": "1.0.3", 249 | "methods": "~1.1.2", 250 | "on-finished": "2.4.1", 251 | "parseurl": "~1.3.3", 252 | "path-to-regexp": "0.1.12", 253 | "proxy-addr": "~2.0.7", 254 | "qs": "6.13.0", 255 | "range-parser": "~1.2.1", 256 | "safe-buffer": "5.2.1", 257 | "send": "0.19.0", 258 | "serve-static": "1.16.2", 259 | "setprototypeof": "1.2.0", 260 | "statuses": "2.0.1", 261 | "type-is": "~1.6.18", 262 | "utils-merge": "1.0.1", 263 | "vary": "~1.1.2" 264 | }, 265 | "engines": { 266 | "node": ">= 0.10.0" 267 | }, 268 | "funding": { 269 | "type": "opencollective", 270 | "url": "https://opencollective.com/express" 271 | } 272 | }, 273 | "node_modules/express/node_modules/cookie": { 274 | "version": "0.7.1", 275 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", 276 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", 277 | "engines": { 278 | "node": ">= 0.6" 279 | } 280 | }, 281 | "node_modules/finalhandler": { 282 | "version": "1.3.1", 283 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 284 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 285 | "dependencies": { 286 | "debug": "2.6.9", 287 | "encodeurl": "~2.0.0", 288 | "escape-html": "~1.0.3", 289 | "on-finished": "2.4.1", 290 | "parseurl": "~1.3.3", 291 | "statuses": "2.0.1", 292 | "unpipe": "~1.0.0" 293 | }, 294 | "engines": { 295 | "node": ">= 0.8" 296 | } 297 | }, 298 | "node_modules/forwarded": { 299 | "version": "0.2.0", 300 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 301 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 302 | "engines": { 303 | "node": ">= 0.6" 304 | } 305 | }, 306 | "node_modules/fresh": { 307 | "version": "0.5.2", 308 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 309 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", 310 | "engines": { 311 | "node": ">= 0.6" 312 | } 313 | }, 314 | "node_modules/function-bind": { 315 | "version": "1.1.2", 316 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 317 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 318 | "funding": { 319 | "url": "https://github.com/sponsors/ljharb" 320 | } 321 | }, 322 | "node_modules/get-intrinsic": { 323 | "version": "1.2.4", 324 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", 325 | "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", 326 | "dependencies": { 327 | "es-errors": "^1.3.0", 328 | "function-bind": "^1.1.2", 329 | "has-proto": "^1.0.1", 330 | "has-symbols": "^1.0.3", 331 | "hasown": "^2.0.0" 332 | }, 333 | "engines": { 334 | "node": ">= 0.4" 335 | }, 336 | "funding": { 337 | "url": "https://github.com/sponsors/ljharb" 338 | } 339 | }, 340 | "node_modules/gopd": { 341 | "version": "1.0.1", 342 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", 343 | "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", 344 | "dependencies": { 345 | "get-intrinsic": "^1.1.3" 346 | }, 347 | "funding": { 348 | "url": "https://github.com/sponsors/ljharb" 349 | } 350 | }, 351 | "node_modules/has-property-descriptors": { 352 | "version": "1.0.2", 353 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 354 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 355 | "dependencies": { 356 | "es-define-property": "^1.0.0" 357 | }, 358 | "funding": { 359 | "url": "https://github.com/sponsors/ljharb" 360 | } 361 | }, 362 | "node_modules/has-proto": { 363 | "version": "1.0.3", 364 | "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", 365 | "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", 366 | "engines": { 367 | "node": ">= 0.4" 368 | }, 369 | "funding": { 370 | "url": "https://github.com/sponsors/ljharb" 371 | } 372 | }, 373 | "node_modules/has-symbols": { 374 | "version": "1.0.3", 375 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", 376 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", 377 | "engines": { 378 | "node": ">= 0.4" 379 | }, 380 | "funding": { 381 | "url": "https://github.com/sponsors/ljharb" 382 | } 383 | }, 384 | "node_modules/hasown": { 385 | "version": "2.0.2", 386 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 387 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 388 | "dependencies": { 389 | "function-bind": "^1.1.2" 390 | }, 391 | "engines": { 392 | "node": ">= 0.4" 393 | } 394 | }, 395 | "node_modules/http-errors": { 396 | "version": "2.0.0", 397 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 398 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 399 | "dependencies": { 400 | "depd": "2.0.0", 401 | "inherits": "2.0.4", 402 | "setprototypeof": "1.2.0", 403 | "statuses": "2.0.1", 404 | "toidentifier": "1.0.1" 405 | }, 406 | "engines": { 407 | "node": ">= 0.8" 408 | } 409 | }, 410 | "node_modules/iconv-lite": { 411 | "version": "0.4.24", 412 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 413 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 414 | "dependencies": { 415 | "safer-buffer": ">= 2.1.2 < 3" 416 | }, 417 | "engines": { 418 | "node": ">=0.10.0" 419 | } 420 | }, 421 | "node_modules/inherits": { 422 | "version": "2.0.4", 423 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 424 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 425 | }, 426 | "node_modules/ipaddr.js": { 427 | "version": "1.9.1", 428 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 429 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 430 | "engines": { 431 | "node": ">= 0.10" 432 | } 433 | }, 434 | "node_modules/media-typer": { 435 | "version": "0.3.0", 436 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 437 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", 438 | "engines": { 439 | "node": ">= 0.6" 440 | } 441 | }, 442 | "node_modules/merge-descriptors": { 443 | "version": "1.0.3", 444 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 445 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", 446 | "funding": { 447 | "url": "https://github.com/sponsors/sindresorhus" 448 | } 449 | }, 450 | "node_modules/methods": { 451 | "version": "1.1.2", 452 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 453 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", 454 | "engines": { 455 | "node": ">= 0.6" 456 | } 457 | }, 458 | "node_modules/mime": { 459 | "version": "1.6.0", 460 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 461 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 462 | "bin": { 463 | "mime": "cli.js" 464 | }, 465 | "engines": { 466 | "node": ">=4" 467 | } 468 | }, 469 | "node_modules/mime-db": { 470 | "version": "1.52.0", 471 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 472 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 473 | "engines": { 474 | "node": ">= 0.6" 475 | } 476 | }, 477 | "node_modules/mime-types": { 478 | "version": "2.1.35", 479 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 480 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 481 | "dependencies": { 482 | "mime-db": "1.52.0" 483 | }, 484 | "engines": { 485 | "node": ">= 0.6" 486 | } 487 | }, 488 | "node_modules/ms": { 489 | "version": "2.0.0", 490 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 491 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 492 | }, 493 | "node_modules/negotiator": { 494 | "version": "0.6.3", 495 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 496 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 497 | "engines": { 498 | "node": ">= 0.6" 499 | } 500 | }, 501 | "node_modules/object-inspect": { 502 | "version": "1.13.2", 503 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", 504 | "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", 505 | "engines": { 506 | "node": ">= 0.4" 507 | }, 508 | "funding": { 509 | "url": "https://github.com/sponsors/ljharb" 510 | } 511 | }, 512 | "node_modules/on-finished": { 513 | "version": "2.4.1", 514 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 515 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 516 | "dependencies": { 517 | "ee-first": "1.1.1" 518 | }, 519 | "engines": { 520 | "node": ">= 0.8" 521 | } 522 | }, 523 | "node_modules/parseurl": { 524 | "version": "1.3.3", 525 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 526 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", 527 | "engines": { 528 | "node": ">= 0.8" 529 | } 530 | }, 531 | "node_modules/path-to-regexp": { 532 | "version": "0.1.12", 533 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", 534 | "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" 535 | }, 536 | "node_modules/proxy-addr": { 537 | "version": "2.0.7", 538 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 539 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 540 | "dependencies": { 541 | "forwarded": "0.2.0", 542 | "ipaddr.js": "1.9.1" 543 | }, 544 | "engines": { 545 | "node": ">= 0.10" 546 | } 547 | }, 548 | "node_modules/qs": { 549 | "version": "6.13.0", 550 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 551 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 552 | "dependencies": { 553 | "side-channel": "^1.0.6" 554 | }, 555 | "engines": { 556 | "node": ">=0.6" 557 | }, 558 | "funding": { 559 | "url": "https://github.com/sponsors/ljharb" 560 | } 561 | }, 562 | "node_modules/range-parser": { 563 | "version": "1.2.1", 564 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 565 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", 566 | "engines": { 567 | "node": ">= 0.6" 568 | } 569 | }, 570 | "node_modules/raw-body": { 571 | "version": "2.5.2", 572 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", 573 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 574 | "dependencies": { 575 | "bytes": "3.1.2", 576 | "http-errors": "2.0.0", 577 | "iconv-lite": "0.4.24", 578 | "unpipe": "1.0.0" 579 | }, 580 | "engines": { 581 | "node": ">= 0.8" 582 | } 583 | }, 584 | "node_modules/safe-buffer": { 585 | "version": "5.2.1", 586 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 587 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 588 | "funding": [ 589 | { 590 | "type": "github", 591 | "url": "https://github.com/sponsors/feross" 592 | }, 593 | { 594 | "type": "patreon", 595 | "url": "https://www.patreon.com/feross" 596 | }, 597 | { 598 | "type": "consulting", 599 | "url": "https://feross.org/support" 600 | } 601 | ] 602 | }, 603 | "node_modules/safer-buffer": { 604 | "version": "2.1.2", 605 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 606 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 607 | }, 608 | "node_modules/send": { 609 | "version": "0.19.0", 610 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 611 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 612 | "dependencies": { 613 | "debug": "2.6.9", 614 | "depd": "2.0.0", 615 | "destroy": "1.2.0", 616 | "encodeurl": "~1.0.2", 617 | "escape-html": "~1.0.3", 618 | "etag": "~1.8.1", 619 | "fresh": "0.5.2", 620 | "http-errors": "2.0.0", 621 | "mime": "1.6.0", 622 | "ms": "2.1.3", 623 | "on-finished": "2.4.1", 624 | "range-parser": "~1.2.1", 625 | "statuses": "2.0.1" 626 | }, 627 | "engines": { 628 | "node": ">= 0.8.0" 629 | } 630 | }, 631 | "node_modules/send/node_modules/encodeurl": { 632 | "version": "1.0.2", 633 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 634 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", 635 | "engines": { 636 | "node": ">= 0.8" 637 | } 638 | }, 639 | "node_modules/send/node_modules/ms": { 640 | "version": "2.1.3", 641 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 642 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 643 | }, 644 | "node_modules/serve-static": { 645 | "version": "1.16.2", 646 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 647 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 648 | "dependencies": { 649 | "encodeurl": "~2.0.0", 650 | "escape-html": "~1.0.3", 651 | "parseurl": "~1.3.3", 652 | "send": "0.19.0" 653 | }, 654 | "engines": { 655 | "node": ">= 0.8.0" 656 | } 657 | }, 658 | "node_modules/set-function-length": { 659 | "version": "1.2.2", 660 | "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", 661 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 662 | "dependencies": { 663 | "define-data-property": "^1.1.4", 664 | "es-errors": "^1.3.0", 665 | "function-bind": "^1.1.2", 666 | "get-intrinsic": "^1.2.4", 667 | "gopd": "^1.0.1", 668 | "has-property-descriptors": "^1.0.2" 669 | }, 670 | "engines": { 671 | "node": ">= 0.4" 672 | } 673 | }, 674 | "node_modules/setprototypeof": { 675 | "version": "1.2.0", 676 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 677 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 678 | }, 679 | "node_modules/side-channel": { 680 | "version": "1.0.6", 681 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", 682 | "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", 683 | "dependencies": { 684 | "call-bind": "^1.0.7", 685 | "es-errors": "^1.3.0", 686 | "get-intrinsic": "^1.2.4", 687 | "object-inspect": "^1.13.1" 688 | }, 689 | "engines": { 690 | "node": ">= 0.4" 691 | }, 692 | "funding": { 693 | "url": "https://github.com/sponsors/ljharb" 694 | } 695 | }, 696 | "node_modules/statuses": { 697 | "version": "2.0.1", 698 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 699 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", 700 | "engines": { 701 | "node": ">= 0.8" 702 | } 703 | }, 704 | "node_modules/toidentifier": { 705 | "version": "1.0.1", 706 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 707 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 708 | "engines": { 709 | "node": ">=0.6" 710 | } 711 | }, 712 | "node_modules/type-is": { 713 | "version": "1.6.18", 714 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 715 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 716 | "dependencies": { 717 | "media-typer": "0.3.0", 718 | "mime-types": "~2.1.24" 719 | }, 720 | "engines": { 721 | "node": ">= 0.6" 722 | } 723 | }, 724 | "node_modules/unpipe": { 725 | "version": "1.0.0", 726 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 727 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", 728 | "engines": { 729 | "node": ">= 0.8" 730 | } 731 | }, 732 | "node_modules/utils-merge": { 733 | "version": "1.0.1", 734 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 735 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", 736 | "engines": { 737 | "node": ">= 0.4.0" 738 | } 739 | }, 740 | "node_modules/vary": { 741 | "version": "1.1.2", 742 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 743 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", 744 | "engines": { 745 | "node": ">= 0.8" 746 | } 747 | } 748 | } 749 | } 750 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linked-role-bot", 3 | "version": "1.0.0", 4 | "private": "true", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18" 8 | }, 9 | "description": "A very simple example of a Linked Role bot for Discord.", 10 | "main": "src/server.js", 11 | "scripts": { 12 | "start": "node src/server.js", 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "keywords": [], 16 | "author": "Justin Beckwith ", 17 | "license": "MIT", 18 | "dependencies": { 19 | "cookie-parser": "^1.4.7", 20 | "dotenv": "^16.5.0", 21 | "express": "^4.21.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":disableDependencyDashboard", 6 | ":preserveSemverRanges" 7 | ], 8 | "ignorePaths": [ 9 | "**/node_modules/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | 3 | /** 4 | * Load environment variables from a .env file, if it exists. 5 | */ 6 | 7 | dotenv.config() 8 | 9 | const config = { 10 | DISCORD_TOKEN: process.env.DISCORD_TOKEN, 11 | DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, 12 | DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, 13 | DISCORD_REDIRECT_URI: process.env.DISCORD_REDIRECT_URI, 14 | COOKIE_SECRET: process.env.COOKIE_SECRET, 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /src/discord.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | import * as storage from './storage.js'; 4 | import config from './config.js'; 5 | 6 | /** 7 | * Code specific to communicating with the Discord API. 8 | */ 9 | 10 | /** 11 | * The following methods all facilitate OAuth2 communication with Discord. 12 | * See https://discord.com/developers/docs/topics/oauth2 for more details. 13 | */ 14 | 15 | /** 16 | * Generate the url which the user will be directed to in order to approve the 17 | * bot, and see the list of requested scopes. 18 | */ 19 | export function getOAuthUrl() { 20 | const state = crypto.randomUUID(); 21 | 22 | const url = new URL('https://discord.com/api/oauth2/authorize'); 23 | url.searchParams.set('client_id', config.DISCORD_CLIENT_ID); 24 | url.searchParams.set('redirect_uri', config.DISCORD_REDIRECT_URI); 25 | url.searchParams.set('response_type', 'code'); 26 | url.searchParams.set('state', state); 27 | url.searchParams.set('scope', 'role_connections.write identify'); 28 | url.searchParams.set('prompt', 'consent'); 29 | return { state, url: url.toString() }; 30 | } 31 | 32 | /** 33 | * Given an OAuth2 code from the scope approval page, make a request to Discord's 34 | * OAuth2 service to retrieve an access token, refresh token, and expiration. 35 | */ 36 | export async function getOAuthTokens(code) { 37 | const url = 'https://discord.com/api/v10/oauth2/token'; 38 | const body = new URLSearchParams({ 39 | client_id: config.DISCORD_CLIENT_ID, 40 | client_secret: config.DISCORD_CLIENT_SECRET, 41 | grant_type: 'authorization_code', 42 | code, 43 | redirect_uri: config.DISCORD_REDIRECT_URI, 44 | }); 45 | 46 | const response = await fetch(url, { 47 | body, 48 | method: 'POST', 49 | headers: { 50 | 'Content-Type': 'application/x-www-form-urlencoded', 51 | }, 52 | }); 53 | if (response.ok) { 54 | const data = await response.json(); 55 | return data; 56 | } else { 57 | throw new Error(`Error fetching OAuth tokens: [${response.status}] ${response.statusText}`); 58 | } 59 | } 60 | 61 | /** 62 | * The initial token request comes with both an access token and a refresh 63 | * token. Check if the access token has expired, and if it has, use the 64 | * refresh token to acquire a new, fresh access token. 65 | */ 66 | export async function getAccessToken(userId, tokens) { 67 | if (Date.now() > tokens.expires_at) { 68 | const url = 'https://discord.com/api/v10/oauth2/token'; 69 | const body = new URLSearchParams({ 70 | client_id: config.DISCORD_CLIENT_ID, 71 | client_secret: config.DISCORD_CLIENT_SECRET, 72 | grant_type: 'refresh_token', 73 | refresh_token: tokens.refresh_token, 74 | }); 75 | const response = await fetch(url, { 76 | body, 77 | method: 'POST', 78 | headers: { 79 | 'Content-Type': 'application/x-www-form-urlencoded', 80 | }, 81 | }); 82 | if (response.ok) { 83 | const tokens = await response.json(); 84 | tokens.expires_at = Date.now() + tokens.expires_in * 1000; 85 | await storage.storeDiscordTokens(userId, tokens); 86 | return tokens.access_token; 87 | } else { 88 | throw new Error(`Error refreshing access token: [${response.status}] ${response.statusText}`); 89 | } 90 | } 91 | return tokens.access_token; 92 | } 93 | 94 | /** 95 | * Given a user based access token, fetch profile information for the current user. 96 | */ 97 | export async function getUserData(tokens) { 98 | const url = 'https://discord.com/api/v10/oauth2/@me'; 99 | const response = await fetch(url, { 100 | headers: { 101 | Authorization: `Bearer ${tokens.access_token}`, 102 | }, 103 | }); 104 | if (response.ok) { 105 | const data = await response.json(); 106 | return data; 107 | } else { 108 | throw new Error(`Error fetching user data: [${response.status}] ${response.statusText}`); 109 | } 110 | } 111 | 112 | /** 113 | * Given metadata that matches the schema, push that data to Discord on behalf 114 | * of the current user. 115 | */ 116 | export async function pushMetadata(userId, tokens, metadata) { 117 | // PUT /users/@me/applications/:id/role-connection 118 | const url = `https://discord.com/api/v10/users/@me/applications/${config.DISCORD_CLIENT_ID}/role-connection`; 119 | const accessToken = await getAccessToken(userId, tokens); 120 | const body = { 121 | platform_name: 'Example Linked Role Discord Bot', 122 | metadata, 123 | }; 124 | const response = await fetch(url, { 125 | method: 'PUT', 126 | body: JSON.stringify(body), 127 | headers: { 128 | Authorization: `Bearer ${accessToken}`, 129 | 'Content-Type': 'application/json', 130 | }, 131 | }); 132 | if (!response.ok) { 133 | throw new Error(`Error pushing discord metadata: [${response.status}] ${response.statusText}`); 134 | } 135 | } 136 | 137 | /** 138 | * Fetch the metadata currently pushed to Discord for the currently logged 139 | * in user, for this specific bot. 140 | */ 141 | export async function getMetadata(userId, tokens) { 142 | // GET /users/@me/applications/:id/role-connection 143 | const url = `https://discord.com/api/v10/users/@me/applications/${config.DISCORD_CLIENT_ID}/role-connection`; 144 | const accessToken = await getAccessToken(userId, tokens); 145 | const response = await fetch(url, { 146 | headers: { 147 | Authorization: `Bearer ${accessToken}`, 148 | }, 149 | }); 150 | if (response.ok) { 151 | const data = await response.json(); 152 | return data; 153 | } else { 154 | throw new Error(`Error getting discord metadata: [${response.status}] ${response.statusText}`); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/register.js: -------------------------------------------------------------------------------- 1 | import config from './config.js'; 2 | 3 | /** 4 | * Register the metadata to be stored by Discord. This should be a one time action. 5 | * Note: uses a Bot token for authentication, not a user token. 6 | */ 7 | const url = `https://discord.com/api/v10/applications/${config.DISCORD_CLIENT_ID}/role-connections/metadata`; 8 | // supported types: number_lt=1, number_gt=2, number_eq=3 number_neq=4, datetime_lt=5, datetime_gt=6, boolean_eq=7, boolean_neq=8 9 | const body = [ 10 | { 11 | key: 'cookieseaten', 12 | name: 'Cookies Eaten', 13 | description: 'Cookies Eaten Greater Than', 14 | type: 2, 15 | }, 16 | { 17 | key: 'allergictonuts', 18 | name: 'Allergic To Nuts', 19 | description: 'Is Allergic To Nuts', 20 | type: 7, 21 | }, 22 | { 23 | key: 'bakingsince', 24 | name: 'Baking Since', 25 | description: 'Days since baking their first cookie', 26 | type: 6, 27 | }, 28 | ]; 29 | 30 | const response = await fetch(url, { 31 | method: 'PUT', 32 | body: JSON.stringify(body), 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | Authorization: `Bot ${config.DISCORD_TOKEN}`, 36 | }, 37 | }); 38 | if (response.ok) { 39 | const data = await response.json(); 40 | console.log(data); 41 | } else { 42 | //throw new Error(`Error pushing discord metadata schema: [${response.status}] ${response.statusText}`); 43 | const data = await response.text(); 44 | console.log(data); 45 | } 46 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cookieParser from 'cookie-parser'; 3 | 4 | import config from './config.js'; 5 | import * as discord from './discord.js'; 6 | import * as storage from './storage.js'; 7 | 8 | /** 9 | * Main HTTP server used for the bot. 10 | */ 11 | 12 | const app = express(); 13 | app.use(cookieParser(config.COOKIE_SECRET)); 14 | 15 | /** 16 | * Just a happy little route to show our server is up. 17 | */ 18 | app.get('/', (req, res) => { 19 | res.send('👋'); 20 | }); 21 | 22 | /** 23 | * Route configured in the Discord developer console which facilitates the 24 | * connection between Discord and any additional services you may use. 25 | * To start the flow, generate the OAuth2 consent dialog url for Discord, 26 | * and redirect the user there. 27 | */ 28 | app.get('/linked-role', async (req, res) => { 29 | const { url, state } = discord.getOAuthUrl(); 30 | 31 | // Store the signed state param in the user's cookies so we can verify 32 | // the value later. See: 33 | // https://discord.com/developers/docs/topics/oauth2#state-and-security 34 | res.cookie('clientState', state, { maxAge: 1000 * 60 * 5, signed: true }); 35 | 36 | // Send the user to the Discord owned OAuth2 authorization endpoint 37 | res.redirect(url); 38 | }); 39 | 40 | /** 41 | * Route configured in the Discord developer console, the redirect Url to which 42 | * the user is sent after approving the bot for their Discord account. This 43 | * completes a few steps: 44 | * 1. Uses the code to acquire Discord OAuth2 tokens 45 | * 2. Uses the Discord Access Token to fetch the user profile 46 | * 3. Stores the OAuth2 Discord Tokens in Redis / Firestore 47 | * 4. Lets the user know it's all good and to go back to Discord 48 | */ 49 | app.get('/discord-oauth-callback', async (req, res) => { 50 | try { 51 | // 1. Uses the code and state to acquire Discord OAuth2 tokens 52 | const code = req.query['code']; 53 | const discordState = req.query['state']; 54 | 55 | // make sure the state parameter exists 56 | const { clientState } = req.signedCookies; 57 | if (clientState !== discordState) { 58 | console.error('State verification failed.'); 59 | return res.sendStatus(403); 60 | } 61 | 62 | const tokens = await discord.getOAuthTokens(code); 63 | 64 | // 2. Uses the Discord Access Token to fetch the user profile 65 | const meData = await discord.getUserData(tokens); 66 | const userId = meData.user.id; 67 | await storage.storeDiscordTokens(userId, { 68 | access_token: tokens.access_token, 69 | refresh_token: tokens.refresh_token, 70 | expires_at: Date.now() + tokens.expires_in * 1000, 71 | }); 72 | 73 | // 3. Update the users metadata, assuming future updates will be posted to the `/update-metadata` endpoint 74 | await updateMetadata(userId); 75 | 76 | res.send('You did it! Now go back to Discord.'); 77 | } catch (e) { 78 | console.error(e); 79 | res.sendStatus(500); 80 | } 81 | }); 82 | 83 | /** 84 | * Example route that would be invoked when an external data source changes. 85 | * This example calls a common `updateMetadata` method that pushes static 86 | * data to Discord. 87 | */ 88 | app.post('/update-metadata', async (req, res) => { 89 | try { 90 | const userId = req.body.userId; 91 | await updateMetadata(userId) 92 | 93 | res.sendStatus(204); 94 | } catch (e) { 95 | res.sendStatus(500); 96 | } 97 | }); 98 | 99 | /** 100 | * Given a Discord UserId, push static make-believe data to the Discord 101 | * metadata endpoint. 102 | */ 103 | async function updateMetadata(userId) { 104 | // Fetch the Discord tokens from storage 105 | const tokens = await storage.getDiscordTokens(userId); 106 | 107 | let metadata = {}; 108 | try { 109 | // Fetch the new metadata you want to use from an external source. 110 | // This data could be POST-ed to this endpoint, but every service 111 | // is going to be different. To keep the example simple, we'll 112 | // just generate some random data. 113 | metadata = { 114 | cookieseaten: 1483, 115 | allergictonuts: 0, // 0 for false, 1 for true 116 | firstcookiebaked: '2003-12-20', 117 | }; 118 | } catch (e) { 119 | e.message = `Error fetching external data: ${e.message}`; 120 | console.error(e); 121 | // If fetching the profile data for the external service fails for any reason, 122 | // ensure metadata on the Discord side is nulled out. This prevents cases 123 | // where the user revokes an external app permissions, and is left with 124 | // stale linked role data. 125 | } 126 | 127 | // Push the data to Discord. 128 | await discord.pushMetadata(userId, tokens, metadata); 129 | } 130 | 131 | 132 | const port = process.env.PORT || 3000; 133 | app.listen(port, () => { 134 | console.log(`Example app listening on port ${port}`); 135 | }); 136 | -------------------------------------------------------------------------------- /src/storage.js: -------------------------------------------------------------------------------- 1 | 2 | const store = new Map(); 3 | 4 | export async function storeDiscordTokens(userId, tokens) { 5 | await store.set(`discord-${userId}`, tokens); 6 | } 7 | 8 | export async function getDiscordTokens(userId) { 9 | return store.get(`discord-${userId}`); 10 | } 11 | --------------------------------------------------------------------------------