Compare commits

..

No commits in common. "main" and "v20201002-034639" have entirely different histories.

584 changed files with 14198 additions and 34968 deletions

View file

@ -1,3 +0,0 @@
module.exports = {
plugins: ['styled-components'],
};

10
.devcontainer/BUILD.bazel Normal file
View file

@ -0,0 +1,10 @@
load("@io_bazel_rules_docker//container:container.bzl", "container_push")
container_push(
name = "publish-dev-container",
format = "Docker",
image = "@dev-container//image:dockerfile_image.tar",
registry = "docker.pkg.github.com",
repository = "roleypoly/roleypoly/dev-container",
tag = "{STABLE_GIT_BRANCH}",
)

14
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM mcr.microsoft.com/vscode/devcontainers/go:1.15
# Install Bazel
ARG BAZEL_VERSION=3.5.0
ARG BAZEL_DOWNLOAD_SHA=dev-mode
RUN curl -fSsL -o /tmp/bazel-installer.sh https://github.com/bazelbuild/bazel/releases/download/${BAZEL_VERSION}/bazel-${BAZEL_VERSION}-installer-linux-x86_64.sh \
&& ([ "${BAZEL_DOWNLOAD_SHA}" = "dev-mode" ] || echo "${BAZEL_DOWNLOAD_SHA} */tmp/bazel-installer.sh" | sha256sum --check - ) \
&& /bin/bash /tmp/bazel-installer.sh --base=/usr/local/bazel \
&& rm /tmp/bazel-installer.sh
# Install Node.js
ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi

View file

@ -1,27 +1,28 @@
{
"name": "Roleypoly (Node)",
"image": "ghcr.io/roleypoly/dev-container:main",
"name": "Roleypoly (Bazel, Go, Node)",
"build": {
"dockerfile": "Dockerfile",
"args": {
"BAZEL_VERSION": "3.5.0",
}
},
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/bin/bash"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"bazelbuild.vscode-bazel",
"dbaeumer.vscode-eslint",
"golang.go",
"hashicorp.terraform",
"firsttris.vscode-jest-runner",
"esbenp.prettier-vscode",
"zxh404.vscode-proto3",
"jpoissonnier.vscode-styled-components",
"eg2.vscode-npm-script",
"christian-kohler.npm-intellisense",
"ms-azuretools.vscode-docker",
"eamodio.gitlens",
"davidanson.vscode-markdownlint",
"stylelint.vscode-stylelint",
"pflannery.vscode-versionlens",
"visualstudioexptteam.vscodeintellicode",
"bungcip.better-toml"
],
"christian-kohler.npm-intellisense"
]
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
@ -29,5 +30,5 @@
// Uncomment when using a ptrace-based debugger like C++, Go, and Rust
// "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
// "remoteUser": "vscode"
}

View file

@ -1,2 +0,0 @@
node_modules
.next

View file

@ -1,21 +0,0 @@
# Make an application at https://discord.com/developers/applications
BOT_CLIENT_ID=000000000000000000
BOT_CLIENT_SECRET=RnX8pXXXXXXXXXXXXXXXXXXXXXXXXXu-
BOT_TOKEN=Mzk2MjI3MTM0MjI3NXXXXXXXXXXXXXXXXXXXXXPUlYoARXXXXXXXXXXXXXX
DISCORD_PUBLIC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
# Comma separated; put your user ID here. Gives elevated permissions to everything.
ROOT_USERS=62601275618889728
# Comma separated; list any bot IDs that are allowed to operate upon this bot.
ALLOWED_BOTS=
# If 6600 or 6601 is taken, change this, and all other 6600/6601 references.
PORT=6609
UI_PORT=6601
# Again, probably right. Do not put a trailing /
UI_PUBLIC_URI=http://localhost:6601
API_PUBLIC_URI=http://localhost:6609
ALLOWED_CALLBACK_HOSTS=http://localhost:6601,https://stage.roleypoly.com,https://next.roleypoly.com,https://roleypoly.com,https://*.roleypoly.pages.dev

View file

@ -1,128 +0,0 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true,
},
extends: ['prettier', 'prettier/@typescript-eslint'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: [
'eslint-plugin-import',
'eslint-plugin-jsdoc',
'eslint-plugin-react',
'@typescript-eslint',
'@typescript-eslint/tslint',
],
rules: {
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/indent': 'off',
'@typescript-eslint/member-delimiter-style': [
'off',
{
multiline: {
delimiter: 'none',
requireLast: true,
},
singleline: {
delimiter: 'semi',
requireLast: false,
},
},
],
'@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowTaggedTemplates: true,
allowShortCircuit: true,
},
],
'@typescript-eslint/prefer-namespace-keyword': 'error',
'@typescript-eslint/quotes': 'off',
'@typescript-eslint/semi': ['off', null],
'@typescript-eslint/triple-slash-reference': [
'error',
{
path: 'always',
types: 'prefer-import',
lib: 'always',
},
],
'@typescript-eslint/type-annotation-spacing': 'off',
'@typescript-eslint/unified-signatures': 'error',
'arrow-parens': ['off', 'always'],
'brace-style': ['off', 'off'],
'comma-dangle': 'off',
curly: ['error', 'multi-line'],
'eol-last': 'off',
eqeqeq: ['error', 'smart'],
'id-blacklist': [
'error',
'any',
'Number',
'number',
'String',
'string',
'Boolean',
'boolean',
'Undefined',
'undefined',
],
'id-match': 'error',
'import/no-deprecated': 'error',
'jsdoc/check-alignment': 'error',
'jsdoc/check-indentation': 'error',
'jsdoc/newline-after-description': 'error',
'linebreak-style': 'off',
'max-len': 'off',
'new-parens': 'off',
'newline-per-chained-call': 'off',
'no-caller': 'error',
'no-cond-assign': 'error',
'no-constant-condition': 'error',
'no-control-regex': 'error',
'no-duplicate-imports': 'error',
'no-empty': 'error',
'no-eval': 'error',
'no-extra-semi': 'off',
'no-fallthrough': 'error',
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'off',
'no-multiple-empty-lines': 'off',
'no-redeclare': 'error',
'no-regex-spaces': 'error',
'no-return-await': 'error',
'no-throw-literal': 'error',
'no-trailing-spaces': 'off',
'no-underscore-dangle': 'error',
'no-unused-labels': 'error',
'no-var': 'error',
'one-var': ['error', 'never'],
'quote-props': 'off',
radix: 'error',
'react/jsx-curly-spacing': 'off',
'react/jsx-equals-spacing': 'off',
'react/jsx-wrap-multilines': 'off',
'space-before-function-paren': 'off',
'space-in-parens': ['off', 'never'],
'spaced-comment': [
'error',
'always',
{
markers: ['/'],
},
],
'use-isnan': 'error',
},
};

2
.github/FUNDING.yml vendored
View file

@ -1,7 +1,7 @@
# These are supported funding model platforms
github: kayteh
patreon: roleypoly
patreon: kata
open_collective: # Replace with a single Open Collective username
ko_fi: roleypoly
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel

View file

@ -1,21 +0,0 @@
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'daily'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'daily'
- package-ecosystem: 'gomod'
directory: '/'
schedule:
interval: 'daily'
- package-ecosystem: 'terraform'
directory: '/terraform'
schedule:
interval: 'daily'

View file

@ -1,91 +1,59 @@
name: Roleypoly CI
name: Bazel Build
on:
push:
pull_request:
on: push
jobs:
node_test:
bazel_build:
name: Bazel Build
runs-on: ubuntu-latest
name: Node CI
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@v2.5.1
- name: Mount bazel cache
uses: actions/cache@v1
with:
node-version: '20'
cache: yarn
path: "/home/runner/.cache/bazel"
key: bazel
- run: yarn install --frozen-lockfile
- run: yarn lint
# - run: yarn test
worker_build:
runs-on: ubuntu-latest
name: Worker Build & Publish
if: startsWith(github.ref, 'refs/heads/dependabot/') != true
needs:
- node_test
strategy:
matrix:
worker:
- api
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@v2.5.1
with:
node-version: '20'
cache: yarn
- id: 'auth'
uses: 'google-github-actions/auth@v0'
with:
credentials_json: '${{ secrets.GCS_TF_KEY }}'
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v0
with:
project_id: ${{ secrets.GCS_PROJECT_ID }}
export_default_credentials: true
- name: Check if already deployed
id: check
- name: Install bazelisk
run: |
gsutil stat gs://roleypoly-artifacts/workers/${{ github.sha }}/index.mjs \
&& echo ::set-output name=skip::1 \
|| echo ::set-output name=skip::0
curl -LO "https://github.com/bazelbuild/bazelisk/releases/download/v1.1.0/bazelisk-linux-amd64"
mkdir -p "${GITHUB_WORKSPACE}/bin/"
mv bazelisk-linux-amd64 "${GITHUB_WORKSPACE}/bin/bazel"
chmod +x "${GITHUB_WORKSPACE}/bin/bazel"
- run: yarn install --frozen-lockfile
if: steps.check.outputs.skip == '0'
- name: Test
run: |
"${GITHUB_WORKSPACE}/bin/bazel" test \
--stamp \
--workspace_status_command hack/workspace_status.sh \
//src/...
- run: |
yarn build:api
if: steps.check.outputs.skip == '0'
- name: Docker Login
run: |
echo ${{github.token}} | docker login -u ${{github.actor}} --password-stdin docker.pkg.github.com
- id: upload-file
if: github.event_name == 'push' && steps.check.outputs.skip == '0'
uses: google-github-actions/upload-cloud-storage@main
- name: Publish Artifacts
run: |
"${GITHUB_WORKSPACE}/bin/bazel" query //src/... |\
grep +publish |\
xargs -l1 "${GITHUB_WORKSPACE}/bin/bazel" run \
--stamp \
--workspace_status_command hack/workspace_status.sh
- name: Write Artifact Manifest
run: |
artifacts=$(${GITHUB_WORKSPACE}/bin/bazel query //src/... | grep +publish)
publishedServices=${artifacts//$'//src/'/}
publishedServices=${publishedServices//$':+publish'/}
manifestJSON='{"services": {}}'
for svc in $publishedServices; do
manifestJSON=$(echo $manifestJSON | jq ".services+={\"$svc\":\"$(cat bazel-bin/src/$svc/+publish.digest)\"}")
done
echo $manifestJSON > manifest.json
- name: Upload Artifact Manifest
uses: actions/upload-artifact@v2
with:
path: packages/api/dist/index.mjs
destination: roleypoly-artifacts/workers/${{ github.sha }}
trigger_deploy:
name: Deploy to Stage
needs:
- worker_build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Invoke Deploy workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Deploy
token: ${{ secrets.GITOPS_TOKEN }}
inputs: |-
{
"environment": "stage",
"worker_tag": "${{ github.sha }}"
}
name: manifest.json
path: manifest.json

View file

@ -1,47 +1,62 @@
name: 'Code Scanning - Action'
name: "CodeQL"
on:
push:
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [main]
schedule:
# ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23)
# │ │ ┌───────────── day of the month (1 - 31)
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
# │ │ │ │ │
# │ │ │ │ │
# │ │ │ │ │
# * * * * *
- cron: '30 1 * * 0'
- cron: '0 1 * * 2'
jobs:
CodeQL-Build:
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['go', 'javascript']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
# Override language selection by uncommenting this and choosing your languages
# with:
# languages: go, javascript, csharp, python, cpp, java
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following
# three lines and modify them (or add more) to build your code if your
# project uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap

View file

@ -1,128 +0,0 @@
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'One of: stage, prod'
required: true
default: stage
bot_tag:
description: 'tag/digest reference to a UI container build'
required: false
default: ':main'
worker_tag:
description: 'bucket key to fetch worker from'
required: false
default: '' # Empty will try using current main branch hash
jobs:
deploy_terraform:
name: Deploy Terraform
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: hashicorp/setup-terraform@v1.3.2
with:
terraform_version: ^1.1.4
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v0
with:
project_id: ${{ secrets.GCS_PROJECT_ID }}
service_account_key: ${{ secrets.GCS_TF_KEY }}
export_default_credentials: true
- name: Get Google Secrets (they keep them in a box under a tree)
id: secrets
uses: google-github-actions/get-secretmanager-secrets@main
with:
secrets: |-
secretJSON:${{ secrets.GCS_PROJECT_ID }}/${{github.event.inputs.environment}}-tfvars
- name: Pull necessary artifacts
working-directory: ./terraform
run: |
currentHash=${{ github.sha }}
targetArtifact=${{ github.event.inputs.worker_tag }}
selected="${targetArtifact:-$currentHash}"
mkdir worker-dist
gsutil cp -r "gs://roleypoly-artifacts/workers/$selected/*" worker-dist/
- name: Terraform init
working-directory: ./terraform
run: |
terraform init --backend-config "prefix=${{github.event.inputs.environment}}"
- name: Write *.auto.tfvars.json files
working-directory: ./terraform
run: |
echo \
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "worker_tag": "${{github.event.inputs.worker_tag}}", "path_to_worker": "./worker-dist/index.mjs"}' \
| jq . \
| tee tags.auto.tfvars.json
echo ${SECRET_TFVARS} > secrets.auto.tfvars.json
env:
SECRET_TFVARS: ${{ steps.secrets.outputs.secretJSON }}
- name: Terraform plan
working-directory: ./terraform
run: |
terraform plan \
-var-file variables/global.tfvars \
-var-file variables/${{github.event.inputs.environment}}.tfvars \
-out=./deployment.tfplan
- name: Terraform apply
working-directory: ./terraform
run: |
terraform apply \
-auto-approve \
deployment.tfplan
- name: Yell Success at Discord
if: success()
run: |
DATA='{
"embeds": [
{
"title": "Roleypoly Deployment Success",
"description": "Roleypoly was successfully deployed at '$(date)'",
"color": 4634182,
"author": {
"name": "Deployment Notification",
"url": "https://github.com/roleypoly/roleypoly/actions/runs/${{ github.run_id }}"
},
"footer": {
"text": "GitHub Actions"
}
}
]
}'
curl -X POST -H "content-type: application/json" --data "$DATA" ${{ secrets.DEPLOYMENT_WEBHOOK_URL }}
- name: Yell Failure at Discord
if: failure()
run: |
DATA='{
"embeds": [
{
"title": "Roleypoly Deployment Failed",
"description": "Roleypoly failed to be deployed at '$(date)'",
"color": 15291219,
"author": {
"name": "Deployment Notification",
"url": "https://github.com/roleypoly/roleypoly/actions/runs/${{ github.run_id }}"
},
"footer": {
"text": "GitHub Actions"
}
}
]
}'
curl -X POST -H "content-type: application/json" --data "$DATA" ${{ secrets.DEPLOYMENT_WEBHOOK_URL }}

View file

@ -1,53 +1,34 @@
name: Dev Container
name: Build Dev Container
on:
push:
paths:
- hack/dockerfiles/dev-container.Dockerfile
- .github/workflows/dev-container.yml
- .devcontainer/Dockerfile
schedule:
- cron: '0 12 * * 2' # 12 noon every tuesday
- cron: "0 12 * * 2" # 12 noon every tuesday
jobs:
docker_build:
name: Docker Build & Publish
dev_container_build:
name: Bazel Build (Dev Container)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/cache@v2.1.6
- name: Mount bazel cache
uses: actions/cache@v1
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
path: "/home/runner/.cache/bazel"
key: bazel
- name: Docker meta
id: docker_meta
uses: crazy-max/ghaction-docker-meta@v1
with:
images: ghcr.io/roleypoly/dev-container
tag-sha: true
- name: Install bazelisk
run: |
curl -LO "https://github.com/bazelbuild/bazelisk/releases/download/v1.1.0/bazelisk-linux-amd64"
mkdir -p "${GITHUB_WORKSPACE}/bin/"
mv bazelisk-linux-amd64 "${GITHUB_WORKSPACE}/bin/bazel"
chmod +x "${GITHUB_WORKSPACE}/bin/bazel"
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
with:
install: true
- name: Login to GitHub Packages Docker Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: roleypoly
password: ${{ secrets.GHCR_PAT }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./hack/dockerfiles/dev-container.Dockerfile
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
- name: Build & Publish Dev Container
run: |
"${GITHUB_WORKSPACE}/bin/bazel" run \
--stamp \
--workspace_status_command hack/workspace_status.sh\
//.devcontainer:publish-dev-container

27
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Release Workflow
on: workflow_dispatch
jobs:
commit_release_tag:
name: Commit Roleypoly Release Tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
with:
ssh-key: ${{secrets.DEPLOY_KEY}}
# - name: Setup Git
# uses: webfactory/ssh-agent@v0.2.0
# with:
# ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- name: Push changes
id: push
run: |
TAG=$(date +v%Y%m%d-%H%M%S)
git config --local user.email "gh-automation@roleypoly.com"
git config --local user.name "Roleypoly Release Automation"
git tag $TAG
git push origin $TAG
echo "::set-output release_tag=${TAG}"

11
.gitignore vendored
View file

@ -1,8 +1,7 @@
bazel-bin
bazel-out
bazel-roleypoly
bazel-testlogs
node_modules
.env
*.log
storybook-static
.next
worker
.devdbs
dist
docker-compose.yaml

1
.husky/.gitignore vendored
View file

@ -1 +0,0 @@
_

View file

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname $0)/_/husky.sh"
yarn lint-staged

1
.nvmrc
View file

@ -1 +0,0 @@
16.13.2

View file

@ -1,9 +0,0 @@
bazel-*
dist
storybook-static
.next
worker
**/dist
terraform
.husky/_
.mf

0
.prettierrc Normal file
View file

View file

@ -1,9 +0,0 @@
module.exports = {
printWidth: 90,
useTabs: false,
tabWidth: 2,
singleQuote: true,
trailingComma: 'es5',
bracketSpacing: true,
semi: true,
};

View file

@ -1,3 +0,0 @@
bazel-*
dist
storybook-static

View file

@ -1,16 +0,0 @@
{
"customSyntax": "@stylelint/postcss-css-in-js",
"extends": [
"stylelint-config-recommended",
"stylelint-config-styled-components"
],
"rules": {
"color-function-notation": "modern",
"shorthand-property-no-redundant-values": true,
"font-weight-notation": "numeric",
"alpha-value-notation": "percentage",
"hue-degree-notation": "angle",
"function-calc-no-unspaced-operator": true,
"length-zero-no-unit": true
}
}

17
.vscode/settings.json vendored
View file

@ -1,15 +1,10 @@
{
"go.inferGopath": false,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.formatOnSave": true,
"bazel.buildifierFixOnFormat": true,
"[starlark]": {
"editor.tabSize": 4
},
"bazel.buildifierFixOnFormat": true,
"editor.formatOnSave": true,
"editor.insertSpaces": true,
"editor.tabSize": 2,
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
}
}

42
BUILD.bazel Normal file
View file

@ -0,0 +1,42 @@
load("@bazel_gazelle//:def.bzl", "gazelle")
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
# gazelle:prefix github.com/roleypoly/roleypoly
# gazelle:exclude hack/**
gazelle(name = "gazelle")
exports_files(
["tsconfig.json"],
visibility = ["//visibility:public"],
)
filegroup(
name = "node_modules",
srcs = glob(
include = [
"node_modules/**/*.js",
"node_modules/**/*.d.ts",
"node_modules/**/*.json",
"node_modules/.bin/*",
],
exclude = [
# Files under test & docs may contain file names that
# are not legal Bazel labels (e.g.,
# node_modules/ecstatic/test/public/中文/檔案.html)
"node_modules/**/test/**",
"node_modules/**/docs/**",
# Files with spaces in the name are not legal Bazel labels
"node_modules/**/* */**",
"node_modules/**/* *",
],
),
)
# Create a tsc_wrapped compiler rule to use in the ts_library
# compiler attribute when using self-managed dependencies
nodejs_binary(
name = "@bazel/typescript/tsc_wrapped",
entry_point = "@npm//:node_modules/@bazel/typescript/internal/tsc_wrapped/tsc_wrapped.js",
# Point bazel to your node_modules to find the entry point
node_modules = "//:node_modules",
)

109
README.md
View file

@ -4,117 +4,26 @@ https://roleypoly.com
Tame your Discord roles.
## Need Help with Roleypoly? 💁‍♀️
### Need Help with Roleypoly?
📚 [Please read through our community documentation.](https://github.com/roleypoly/community-docs)
😕 [Still confused? Talk to us on Discord!](https://discord.gg/PWQUVsd)
## Developing
Roleypoly is a distributed system built with TypeScript, React, Terraform, and Go.
Roleypoly is a distributed system built with Go, React, Terraform, and Bazel.
This app is heavily edge computing-based with the backend being deployed via Cloudflare Workers, UI server on Google Cloud Run with 8 regions, and the mention responder in Google Compute Engine.
### Extra Development Docs
- 🏭 [Infrastructure](docs/infrastructure.md)
- 🧾 [User Stories](docs/user-stories.md)
This repo is currently being re-architected into a monorepo, so most processes might not be documented.
### Quickstart
#### Option 1 🚀: E2E Dockerized Emulation
This repo can be quickly setup with [VSCode Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) or [GitHub Codespaces](https://github.com/codespaces). This will setup a fully featured Docker container for developing VSCode, including extensions.
This is the fastest way to start. You must be using MacOS or Linux (WSL2 is ok!) for this to be successful.
If you'd like to not use either of those, a docker image can be built from `.devcontainers/Dockerfile`, or used normally via `docker.pkg.github.com/roleypoly/roleypoly/dev-container`. This use case is not actively investigated, but with tinkering, will work. Feel free to document this process and open a PR :)
- Setup `.env` using [`.env.example`][envexample] as a template and guide.
- When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL.
- Run: `yarn install`
- Run: `docker-compose up`
- This starts the UI and API servers in hot-reload dev/emulation mode. All changes to TS/TSX files should be properly captured and reloaded for you!
- Develop you a Roleypoly!
### Things to Know
#### Option 2 🐱‍👤: Local Emulation
Bazel can make some tasks far harder normal. Ideally, these are automated over.
- With pre-requisites:
- Node.js 14, Yarn
- Setup `.env` using [`.env.example`][envexample] as a template and guide.
- When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL.
- Run: `yarn install`
- Run: `yarn start`
- This starts the Web UI, Storybook, and API servers in hot-reload dev/emulation mode. All changes to TS/TSX files should be properly captured and reloaded for you!
- Develop you a Roleypoly!
#### Option 3 🐄🤠: Wrangler (No emulation)
**Outdated. This won't work, but could give you an idea of what to do.**
This is probably extremely painful and requires you to have a Cloudflare account.
- With pre-requisites:
- Cloudflare Account
- Node.js 14, Yarn
- `npm i -g @cloudflare/wrangler`
- Do `wrangler init`, `wrangler login`, etc...
- Setup Wrangler for the project
- Change `account_id` to your Cloudflare Account ID in `wrangler.toml`
- Add a `dev` environment to `wrangler.toml`, using [`.env.example`][envexample] as a reference for how values should be set
- When setting up your Discord Application, be sure to set `http://localhost:8787/login-callback` as the OAuth2 callback URL.
```toml
[env.dev]
[env.dev.vars]
BOT_CLIENT_ID = ...
UI_PUBLIC_URI = "http://localhost:6601"
API_PUBLIC_URI = "http://localhost:8787"
ROOT_USERS = ...
```
- `wrangler secret put BOT_TOKEN -e dev`
- `wrangler secret put BOT_CLIENT_SECRET -e dev`
- Setup KV Namespaces -- Please follow the instructions listed after the command runs.
- `wrangler kvnamespace create -e dev KV_SESSIONS --preview`
- `wrangler kvnamespace create -e dev KV_GUILD_DATA --preview`
- `wrangler kvnamespace create -e dev KV_GUILDS --preview`
- Setup `.env` using [`.env.example`][envexample] as a template and guide.
- Run `yarn install`
- Run both `wrangler dev -e dev` and `yarn start:web`
- This starts the Web UI and API servers in hot-reload dev mode. All changes to TS/TSX files should be properly captured and reloaded for you!
- Develop you a Roleypoly
- And get a beer or heated plant because oh no.
### Developing Design System Components
For working with the [Roleypoly Design System](https://ui.roleypoly.com), use the below steps as reference. Code lives in `src/design-system` among elsewhere.
Run:
- `yarn` to install deps
- `yarn start:design-system` to open storybook
- `yarn test:design-system` to test
### Developing Web UI
For working with the Next.js frontend components, use the below steps as reference. Code lives in `src/web` among elsewhere.
Run:
- `yarn` to install deps
- `yarn start:web` to run Next.js dev server
- `yarn test:web` to test
### Developing API Components
For working with the API, use the below steps as reference. Code lives in `src/api`.
Run:
- `yarn` to install deps
- `yarn start:api` to start an emulated worker
- `yarn test:api` to test
[envexample]: .env.example
- **Updating `go.mod`?**
- Run `hack/gazelle.sh` to regenerate `deps.bzl`.

100
WORKSPACE Normal file
View file

@ -0,0 +1,100 @@
workspace(
name = "roleypoly",
managed_directories = {
"@npm": ["node_modules"],
},
)
### BAZEL
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "io_bazel_rules_go",
sha256 = "08c3cd71857d58af3cda759112437d9e63339ac9c6e0042add43f4d94caf632d",
urls = [
"https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.2/rules_go-v0.24.2.tar.gz",
"https://github.com/bazelbuild/rules_go/releases/download/v0.24.2/rules_go-v0.24.2.tar.gz",
],
)
http_archive(
name = "bazel_gazelle",
sha256 = "d4113967ab451dd4d2d767c3ca5f927fec4b30f3b2c6f8135a2033b9c05a5687",
urls = [
"https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.0/bazel-gazelle-v0.22.0.tar.gz",
"https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.0/bazel-gazelle-v0.22.0.tar.gz",
],
)
http_archive(
name = "build_bazel_rules_nodejs",
sha256 = "b16a03bf63952ae436185c74a5c63bec03c010ed422e230db526af55441a02dd",
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.1.0/rules_nodejs-2.1.0.tar.gz"],
)
http_archive(
name = "io_bazel_rules_docker",
sha256 = "4521794f0fba2e20f3bf15846ab5e01d5332e587e9ce81629c7f96c793bb7036",
strip_prefix = "rules_docker-0.14.4",
urls = ["https://github.com/bazelbuild/rules_docker/releases/download/v0.14.4/rules_docker-v0.14.4.tar.gz"],
)
### GO
load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")
load("//:deps.bzl", "go_repositories")
# gazelle:repository_macro deps.bzl%go_repositories
go_repositories()
go_rules_dependencies()
go_register_toolchains()
gazelle_dependencies()
### NODE
load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories")
node_repositories(
package_json = ["//:package.json"],
)
### DOCKER/CONTAINER
load(
"@io_bazel_rules_docker//repositories:repositories.bzl",
container_repositories = "repositories",
)
container_repositories()
load(
"@io_bazel_rules_docker//go:image.bzl",
_go_image_repos = "repositories",
)
_go_image_repos()
load("@io_bazel_rules_docker//repositories:deps.bzl", container_deps = "deps")
container_deps()
load("@io_bazel_rules_docker//repositories:pip_repositories.bzl", "pip_deps")
pip_deps()
# Dev Container stuff
load("@io_bazel_rules_docker//contrib:dockerfile_build.bzl", "dockerfile_image")
load("@io_bazel_rules_docker//container:container.bzl", "container_pull")
container_pull(
name = "devcontainergo",
registry = "mcr.microsoft.com",
repository = "vscode/devcontainers/go",
tag = "1.15",
)
dockerfile_image(
name = "dev-container",
dockerfile = "//.devcontainer:Dockerfile",
)

813
deps.bzl Normal file
View file

@ -0,0 +1,813 @@
load("@bazel_gazelle//:deps.bzl", "go_repository")
def go_repositories():
go_repository(
name = "co_honnef_go_tools",
importpath = "honnef.co/go/tools",
sum = "h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=",
version = "v0.0.1-2019.2.3",
)
go_repository(
name = "com_github_alecthomas_template",
importpath = "github.com/alecthomas/template",
sum = "h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=",
version = "v0.0.0-20160405071501-a0175ee3bccc",
)
go_repository(
name = "com_github_alecthomas_units",
importpath = "github.com/alecthomas/units",
sum = "h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=",
version = "v0.0.0-20151022065526-2efee857e7cf",
)
go_repository(
name = "com_github_armon_consul_api",
importpath = "github.com/armon/consul-api",
sum = "h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=",
version = "v0.0.0-20180202201655-eb2c6b5be1b6",
)
go_repository(
name = "com_github_beorn7_perks",
importpath = "github.com/beorn7/perks",
sum = "h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0=",
version = "v1.0.0",
)
go_repository(
name = "com_github_burntsushi_toml",
importpath = "github.com/BurntSushi/toml",
sum = "h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=",
version = "v0.3.1",
)
go_repository(
name = "com_github_bwmarrin_discordgo",
importpath = "github.com/bwmarrin/discordgo",
sum = "h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM=",
version = "v0.22.0",
)
go_repository(
name = "com_github_cespare_xxhash",
importpath = "github.com/cespare/xxhash",
sum = "h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=",
version = "v1.1.0",
)
go_repository(
name = "com_github_client9_misspell",
importpath = "github.com/client9/misspell",
sum = "h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=",
version = "v0.3.4",
)
go_repository(
name = "com_github_coreos_bbolt",
importpath = "github.com/coreos/bbolt",
sum = "h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s=",
version = "v1.3.2",
)
go_repository(
name = "com_github_coreos_etcd",
importpath = "github.com/coreos/etcd",
sum = "h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04=",
version = "v3.3.10+incompatible",
)
go_repository(
name = "com_github_coreos_go_semver",
importpath = "github.com/coreos/go-semver",
sum = "h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazuY=",
version = "v0.2.0",
)
go_repository(
name = "com_github_coreos_go_systemd",
importpath = "github.com/coreos/go-systemd",
sum = "h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=",
version = "v0.0.0-20190321100706-95778dfbb74e",
)
go_repository(
name = "com_github_coreos_pkg",
importpath = "github.com/coreos/pkg",
sum = "h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg=",
version = "v0.0.0-20180928190104-399ea9e2e55f",
)
go_repository(
name = "com_github_cpuguy83_go_md2man_v2",
importpath = "github.com/cpuguy83/go-md2man/v2",
sum = "h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=",
version = "v2.0.0",
)
go_repository(
name = "com_github_data_dog_go_sqlmock",
importpath = "github.com/DATA-DOG/go-sqlmock",
sum = "h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=",
version = "v1.5.0",
)
go_repository(
name = "com_github_davecgh_go_spew",
importpath = "github.com/davecgh/go-spew",
sum = "h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=",
version = "v1.1.1",
)
go_repository(
name = "com_github_dghubble_trie",
importpath = "github.com/dghubble/trie",
sum = "h1:euE/xPG0HIg6XiNXYrAHxX9aVwD1gw/yM2kptLOOj6k=",
version = "v0.0.0-20200716043226-5a94efb202d5",
)
go_repository(
name = "com_github_dgrijalva_jwt_go",
importpath = "github.com/dgrijalva/jwt-go",
sum = "h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=",
version = "v3.2.0+incompatible",
)
go_repository(
name = "com_github_dgryski_go_sip13",
importpath = "github.com/dgryski/go-sip13",
sum = "h1:RMLoZVzv4GliuWafOuPuQDKSm1SJph7uCRnnS61JAn4=",
version = "v0.0.0-20181026042036-e10d5fee7954",
)
go_repository(
name = "com_github_facebook_ent",
importpath = "github.com/facebook/ent",
sum = "h1:ds9HENceKzpGBgCRlkZNq6TqBIegwKcF3e5reuV9Z0M=",
version = "v0.4.3",
)
go_repository(
name = "com_github_fsnotify_fsnotify",
importpath = "github.com/fsnotify/fsnotify",
sum = "h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=",
version = "v1.4.7",
)
go_repository(
name = "com_github_ghodss_yaml",
importpath = "github.com/ghodss/yaml",
sum = "h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=",
version = "v1.0.0",
)
go_repository(
name = "com_github_go_bindata_go_bindata",
importpath = "github.com/go-bindata/go-bindata",
sum = "h1:WNHfSP1q2vuAa9vF54RrhCl4nqxCjVcXhlbsRXbGOSY=",
version = "v1.0.1-0.20190711162640-ee3c2418e368",
)
go_repository(
name = "com_github_go_kit_kit",
importpath = "github.com/go-kit/kit",
sum = "h1:Wz+5lgoB0kkuqLEc6NVmwRknTKP6dTGbSqvhZtBI/j0=",
version = "v0.8.0",
)
go_repository(
name = "com_github_go_logfmt_logfmt",
importpath = "github.com/go-logfmt/logfmt",
sum = "h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA=",
version = "v0.4.0",
)
go_repository(
name = "com_github_go_logr_logr",
importpath = "github.com/go-logr/logr",
sum = "h1:M1Tv3VzNlEHg6uyACnRdtrploV2P7wZqH8BoQMtz0cg=",
version = "v0.1.0",
)
go_repository(
name = "com_github_go_openapi_inflect",
importpath = "github.com/go-openapi/inflect",
sum = "h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=",
version = "v0.19.0",
)
go_repository(
name = "com_github_go_sql_driver_mysql",
importpath = "github.com/go-sql-driver/mysql",
sum = "h1:L6V0ANsMIMdLgXly241UXhXNFWYgXbgjHupTAAURrV0=",
version = "v1.5.1-0.20200311113236-681ffa848bae",
)
go_repository(
name = "com_github_go_stack_stack",
importpath = "github.com/go-stack/stack",
sum = "h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=",
version = "v1.8.0",
)
go_repository(
name = "com_github_gogo_protobuf",
importpath = "github.com/gogo/protobuf",
sum = "h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=",
version = "v1.2.1",
)
go_repository(
name = "com_github_golang_glog",
importpath = "github.com/golang/glog",
sum = "h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=",
version = "v0.0.0-20160126235308-23def4e6c14b",
)
go_repository(
name = "com_github_golang_groupcache",
importpath = "github.com/golang/groupcache",
sum = "h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=",
version = "v0.0.0-20200121045136-8c9f03a8e57e",
)
go_repository(
name = "com_github_golang_mock",
importpath = "github.com/golang/mock",
sum = "h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8=",
version = "v1.1.1",
)
go_repository(
name = "com_github_golang_protobuf",
importpath = "github.com/golang/protobuf",
sum = "h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=",
version = "v1.3.2",
)
go_repository(
name = "com_github_google_btree",
importpath = "github.com/google/btree",
sum = "h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=",
version = "v1.0.0",
)
go_repository(
name = "com_github_google_go_cmp",
importpath = "github.com/google/go-cmp",
sum = "h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=",
version = "v0.3.0",
)
go_repository(
name = "com_github_google_go_github_v32",
importpath = "github.com/google/go-github/v32",
sum = "h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=",
version = "v32.1.0",
)
go_repository(
name = "com_github_google_go_querystring",
importpath = "github.com/google/go-querystring",
sum = "h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=",
version = "v1.0.0",
)
go_repository(
name = "com_github_google_gofuzz",
importpath = "github.com/google/gofuzz",
sum = "h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=",
version = "v1.0.0",
)
go_repository(
name = "com_github_google_renameio",
importpath = "github.com/google/renameio",
sum = "h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=",
version = "v0.1.0",
)
go_repository(
name = "com_github_google_uuid",
importpath = "github.com/google/uuid",
sum = "h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=",
version = "v1.1.2",
)
go_repository(
name = "com_github_gorilla_websocket",
importpath = "github.com/gorilla/websocket",
sum = "h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=",
version = "v1.4.2",
)
go_repository(
name = "com_github_grpc_ecosystem_go_grpc_middleware",
importpath = "github.com/grpc-ecosystem/go-grpc-middleware",
sum = "h1:Iju5GlWwrvL6UBg4zJJt3btmonfrMlCDdsejg4CZE7c=",
version = "v1.0.0",
)
go_repository(
name = "com_github_grpc_ecosystem_go_grpc_prometheus",
importpath = "github.com/grpc-ecosystem/go-grpc-prometheus",
sum = "h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=",
version = "v1.2.0",
)
go_repository(
name = "com_github_grpc_ecosystem_grpc_gateway",
importpath = "github.com/grpc-ecosystem/grpc-gateway",
sum = "h1:bM6ZAFZmc/wPFaRDi0d5L7hGEZEx/2u+Tmr2evNHDiI=",
version = "v1.9.0",
)
go_repository(
name = "com_github_hashicorp_hcl",
importpath = "github.com/hashicorp/hcl",
sum = "h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=",
version = "v1.0.0",
)
go_repository(
name = "com_github_inconshreveable_mousetrap",
importpath = "github.com/inconshreveable/mousetrap",
sum = "h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=",
version = "v1.0.0",
)
go_repository(
name = "com_github_jessevdk_go_flags",
importpath = "github.com/jessevdk/go-flags",
sum = "h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=",
version = "v1.4.0",
)
go_repository(
name = "com_github_joho_godotenv",
importpath = "github.com/joho/godotenv",
sum = "h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=",
version = "v1.3.0",
)
go_repository(
name = "com_github_jonboulle_clockwork",
importpath = "github.com/jonboulle/clockwork",
sum = "h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=",
version = "v0.1.0",
)
go_repository(
name = "com_github_json_iterator_go",
importpath = "github.com/json-iterator/go",
sum = "h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=",
version = "v1.1.10",
)
go_repository(
name = "com_github_julienschmidt_httprouter",
importpath = "github.com/julienschmidt/httprouter",
sum = "h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=",
version = "v1.3.0",
)
go_repository(
name = "com_github_kisielk_errcheck",
importpath = "github.com/kisielk/errcheck",
sum = "h1:reN85Pxc5larApoH1keMBiu2GWtPqXQ1nc9gx+jOU+E=",
version = "v1.2.0",
)
go_repository(
name = "com_github_kisielk_gotool",
importpath = "github.com/kisielk/gotool",
sum = "h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=",
version = "v1.0.0",
)
go_repository(
name = "com_github_konsorten_go_windows_terminal_sequences",
importpath = "github.com/konsorten/go-windows-terminal-sequences",
sum = "h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=",
version = "v1.0.1",
)
go_repository(
name = "com_github_kr_logfmt",
importpath = "github.com/kr/logfmt",
sum = "h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY=",
version = "v0.0.0-20140226030751-b84e30acd515",
)
go_repository(
name = "com_github_kr_pretty",
importpath = "github.com/kr/pretty",
sum = "h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=",
version = "v0.1.0",
)
go_repository(
name = "com_github_kr_pty",
importpath = "github.com/kr/pty",
sum = "h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=",
version = "v1.1.1",
)
go_repository(
name = "com_github_kr_text",
importpath = "github.com/kr/text",
sum = "h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=",
version = "v0.1.0",
)
go_repository(
name = "com_github_lampjaw_discordclient",
importpath = "github.com/lampjaw/discordclient",
sum = "h1:Y2o9fEOoAYjCw8IDyxUVaBq44AUbOLyPnYSPpM6Ef3M=",
version = "v0.0.0-20200923011548-6558fc9e89df",
)
go_repository(
name = "com_github_lib_pq",
importpath = "github.com/lib/pq",
sum = "h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=",
version = "v1.8.0",
)
go_repository(
name = "com_github_magiconair_properties",
importpath = "github.com/magiconair/properties",
sum = "h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=",
version = "v1.8.0",
)
go_repository(
name = "com_github_mattn_go_runewidth",
importpath = "github.com/mattn/go-runewidth",
sum = "h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=",
version = "v0.0.9",
)
go_repository(
name = "com_github_mattn_go_sqlite3",
importpath = "github.com/mattn/go-sqlite3",
sum = "h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA=",
version = "v1.14.3",
)
go_repository(
name = "com_github_matttproud_golang_protobuf_extensions",
importpath = "github.com/matttproud/golang_protobuf_extensions",
sum = "h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=",
version = "v1.0.1",
)
go_repository(
name = "com_github_mitchellh_go_homedir",
importpath = "github.com/mitchellh/go-homedir",
sum = "h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=",
version = "v1.1.0",
)
go_repository(
name = "com_github_mitchellh_mapstructure",
importpath = "github.com/mitchellh/mapstructure",
sum = "h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8=",
version = "v1.3.3",
)
go_repository(
name = "com_github_modern_go_concurrent",
importpath = "github.com/modern-go/concurrent",
sum = "h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=",
version = "v0.0.0-20180306012644-bacd9c7ef1dd",
)
go_repository(
name = "com_github_modern_go_reflect2",
importpath = "github.com/modern-go/reflect2",
sum = "h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=",
version = "v1.0.1",
)
go_repository(
name = "com_github_mwitkow_go_conntrack",
importpath = "github.com/mwitkow/go-conntrack",
sum = "h1:F9x/1yl3T2AeKLr2AMdilSD8+f9bvMnNN8VS5iDtovc=",
version = "v0.0.0-20161129095857-cc309e4a2223",
)
go_repository(
name = "com_github_oklog_ulid",
importpath = "github.com/oklog/ulid",
sum = "h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=",
version = "v1.3.1",
)
go_repository(
name = "com_github_olekukonko_tablewriter",
importpath = "github.com/olekukonko/tablewriter",
sum = "h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=",
version = "v0.0.4",
)
go_repository(
name = "com_github_oneofone_xxhash",
importpath = "github.com/OneOfOne/xxhash",
sum = "h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=",
version = "v1.2.2",
)
go_repository(
name = "com_github_pelletier_go_toml",
importpath = "github.com/pelletier/go-toml",
sum = "h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=",
version = "v1.2.0",
)
go_repository(
name = "com_github_pkg_errors",
importpath = "github.com/pkg/errors",
sum = "h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=",
version = "v0.9.1",
)
go_repository(
name = "com_github_pmezard_go_difflib",
importpath = "github.com/pmezard/go-difflib",
sum = "h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=",
version = "v1.0.0",
)
go_repository(
name = "com_github_prometheus_client_golang",
importpath = "github.com/prometheus/client_golang",
sum = "h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8=",
version = "v0.9.3",
)
go_repository(
name = "com_github_prometheus_client_model",
importpath = "github.com/prometheus/client_model",
sum = "h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE=",
version = "v0.0.0-20190129233127-fd36f4220a90",
)
go_repository(
name = "com_github_prometheus_common",
importpath = "github.com/prometheus/common",
sum = "h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM=",
version = "v0.4.0",
)
go_repository(
name = "com_github_prometheus_procfs",
importpath = "github.com/prometheus/procfs",
sum = "h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY=",
version = "v0.0.0-20190507164030-5867b95ac084",
)
go_repository(
name = "com_github_prometheus_tsdb",
importpath = "github.com/prometheus/tsdb",
sum = "h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA=",
version = "v0.7.1",
)
go_repository(
name = "com_github_rogpeppe_fastuuid",
importpath = "github.com/rogpeppe/fastuuid",
sum = "h1:gu+uRPtBe88sKxUCEXRoeCvVG90TJmwhiqRpvdhQFng=",
version = "v0.0.0-20150106093220-6724a57986af",
)
go_repository(
name = "com_github_rogpeppe_go_internal",
importpath = "github.com/rogpeppe/go-internal",
sum = "h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=",
version = "v1.3.0",
)
go_repository(
name = "com_github_russross_blackfriday_v2",
importpath = "github.com/russross/blackfriday/v2",
sum = "h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=",
version = "v2.0.1",
)
go_repository(
name = "com_github_segmentio_ksuid",
importpath = "github.com/segmentio/ksuid",
sum = "h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY=",
version = "v1.0.3",
)
go_repository(
name = "com_github_shurcool_sanitized_anchor_name",
importpath = "github.com/shurcooL/sanitized_anchor_name",
sum = "h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=",
version = "v1.0.0",
)
go_repository(
name = "com_github_sirupsen_logrus",
importpath = "github.com/sirupsen/logrus",
sum = "h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=",
version = "v1.2.0",
)
go_repository(
name = "com_github_soheilhy_cmux",
importpath = "github.com/soheilhy/cmux",
sum = "h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E=",
version = "v0.1.4",
)
go_repository(
name = "com_github_spaolacci_murmur3",
importpath = "github.com/spaolacci/murmur3",
sum = "h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=",
version = "v0.0.0-20180118202830-f09979ecbc72",
)
go_repository(
name = "com_github_spf13_afero",
importpath = "github.com/spf13/afero",
sum = "h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=",
version = "v1.1.2",
)
go_repository(
name = "com_github_spf13_cast",
importpath = "github.com/spf13/cast",
sum = "h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=",
version = "v1.3.0",
)
go_repository(
name = "com_github_spf13_cobra",
importpath = "github.com/spf13/cobra",
sum = "h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=",
version = "v1.0.0",
)
go_repository(
name = "com_github_spf13_jwalterweatherman",
importpath = "github.com/spf13/jwalterweatherman",
sum = "h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=",
version = "v1.0.0",
)
go_repository(
name = "com_github_spf13_pflag",
importpath = "github.com/spf13/pflag",
sum = "h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=",
version = "v1.0.5",
)
go_repository(
name = "com_github_spf13_viper",
importpath = "github.com/spf13/viper",
sum = "h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=",
version = "v1.4.0",
)
go_repository(
name = "com_github_stretchr_objx",
importpath = "github.com/stretchr/objx",
sum = "h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=",
version = "v0.2.0",
)
go_repository(
name = "com_github_stretchr_testify",
importpath = "github.com/stretchr/testify",
sum = "h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=",
version = "v1.6.1",
)
go_repository(
name = "com_github_tmc_grpc_websocket_proxy",
importpath = "github.com/tmc/grpc-websocket-proxy",
sum = "h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ=",
version = "v0.0.0-20190109142713-0ad062ec5ee5",
)
go_repository(
name = "com_github_ugorji_go",
importpath = "github.com/ugorji/go",
sum = "h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw=",
version = "v1.1.4",
)
go_repository(
name = "com_github_xiang90_probing",
importpath = "github.com/xiang90/probing",
sum = "h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=",
version = "v0.0.0-20190116061207-43a291ad63a2",
)
go_repository(
name = "com_github_xordataexchange_crypt",
importpath = "github.com/xordataexchange/crypt",
sum = "h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow=",
version = "v0.0.3-0.20170626215501-b2862e3d0a77",
)
go_repository(
name = "com_github_yuin_goldmark",
importpath = "github.com/yuin/goldmark",
sum = "h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=",
version = "v1.2.1",
)
go_repository(
name = "com_google_cloud_go",
importpath = "cloud.google.com/go",
sum = "h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=",
version = "v0.26.0",
)
go_repository(
name = "in_gopkg_alecthomas_kingpin_v2",
importpath = "gopkg.in/alecthomas/kingpin.v2",
sum = "h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=",
version = "v2.2.6",
)
go_repository(
name = "in_gopkg_check_v1",
importpath = "gopkg.in/check.v1",
sum = "h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=",
version = "v1.0.0-20180628173108-788fd7840127",
)
go_repository(
name = "in_gopkg_errgo_v2",
importpath = "gopkg.in/errgo.v2",
sum = "h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=",
version = "v2.1.0",
)
go_repository(
name = "in_gopkg_resty_v1",
importpath = "gopkg.in/resty.v1",
sum = "h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI=",
version = "v1.12.0",
)
go_repository(
name = "in_gopkg_yaml_v2",
importpath = "gopkg.in/yaml.v2",
sum = "h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=",
version = "v2.2.2",
)
go_repository(
name = "in_gopkg_yaml_v3",
importpath = "gopkg.in/yaml.v3",
sum = "h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=",
version = "v3.0.0-20200313102051-9f266ea9e77c",
)
go_repository(
name = "io_etcd_go_bbolt",
importpath = "go.etcd.io/bbolt",
sum = "h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk=",
version = "v1.3.2",
)
go_repository(
name = "io_k8s_klog",
importpath = "k8s.io/klog",
sum = "h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=",
version = "v1.0.0",
)
go_repository(
name = "io_opencensus_go",
importpath = "go.opencensus.io",
sum = "h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=",
version = "v0.22.4",
)
go_repository(
name = "org_golang_google_appengine",
importpath = "google.golang.org/appengine",
sum = "h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=",
version = "v1.4.0",
)
go_repository(
name = "org_golang_google_genproto",
importpath = "google.golang.org/genproto",
sum = "h1:i1Ppqkc3WQXikh8bXiwHqAN5Rv3/qDCcRk0/Otx73BY=",
version = "v0.0.0-20190425155659-357c62f0e4bb",
)
go_repository(
name = "org_golang_google_grpc",
importpath = "google.golang.org/grpc",
sum = "h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0=",
version = "v1.21.0",
)
go_repository(
name = "org_golang_x_crypto",
importpath = "golang.org/x/crypto",
sum = "h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=",
version = "v0.0.0-20200622213623-75b288015ac9",
)
go_repository(
name = "org_golang_x_exp",
importpath = "golang.org/x/exp",
sum = "h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA=",
version = "v0.0.0-20190121172915-509febef88a4",
)
go_repository(
name = "org_golang_x_lint",
importpath = "golang.org/x/lint",
sum = "h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=",
version = "v0.0.0-20190930215403-16217165b5de",
)
go_repository(
name = "org_golang_x_mod",
importpath = "golang.org/x/mod",
sum = "h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=",
version = "v0.3.0",
)
go_repository(
name = "org_golang_x_net",
importpath = "golang.org/x/net",
sum = "h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=",
version = "v0.0.0-20200822124328-c89045814202",
)
go_repository(
name = "org_golang_x_oauth2",
importpath = "golang.org/x/oauth2",
sum = "h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=",
version = "v0.0.0-20180821212333-d2e6202438be",
)
go_repository(
name = "org_golang_x_sync",
importpath = "golang.org/x/sync",
sum = "h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA=",
version = "v0.0.0-20200625203802-6e8e738ad208",
)
go_repository(
name = "org_golang_x_sys",
importpath = "golang.org/x/sys",
sum = "h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=",
version = "v0.0.0-20200323222414-85ca7c5b95cd",
)
go_repository(
name = "org_golang_x_text",
importpath = "golang.org/x/text",
sum = "h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=",
version = "v0.3.2",
)
go_repository(
name = "org_golang_x_time",
importpath = "golang.org/x/time",
sum = "h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=",
version = "v0.0.0-20190308202827-9d24e82272b4",
)
go_repository(
name = "org_golang_x_tools",
importpath = "golang.org/x/tools",
sum = "h1:xLt+iB5ksWcZVxqc+g9K41ZHy+6MKWfXCDsjSThnsPA=",
version = "v0.0.0-20200904185747-39188db58858",
)
go_repository(
name = "org_golang_x_xerrors",
importpath = "golang.org/x/xerrors",
sum = "h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=",
version = "v0.0.0-20200804184101-5ec99f83aff1",
)
go_repository(
name = "org_uber_go_atomic",
importpath = "go.uber.org/atomic",
sum = "h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=",
version = "v1.5.0",
)
go_repository(
name = "org_uber_go_dig",
importpath = "go.uber.org/dig",
sum = "h1:yLmDDj9/zuDjv3gz8GQGviXMs9TfysIUMUilCpgzUJY=",
version = "v1.10.0",
)
go_repository(
name = "org_uber_go_fx",
importpath = "go.uber.org/fx",
sum = "h1:CFNTr1oin5OJ0VCZ8EycL3wzF29Jz2g0xe55RFsf2a4=",
version = "v1.13.1",
)
go_repository(
name = "org_uber_go_goleak",
importpath = "go.uber.org/goleak",
sum = "h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=",
version = "v0.10.0",
)
go_repository(
name = "org_uber_go_multierr",
importpath = "go.uber.org/multierr",
sum = "h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=",
version = "v1.4.0",
)
go_repository(
name = "org_uber_go_tools",
importpath = "go.uber.org/tools",
sum = "h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=",
version = "v0.0.0-20190618225709-2cfd321de3ee",
)
go_repository(
name = "org_uber_go_zap",
importpath = "go.uber.org/zap",
sum = "h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM=",
version = "v1.10.0",
)

View file

@ -1,14 +0,0 @@
# This is the Docker Compose for setting up a local dev environment.
version: '3.8'
services:
dev:
image: node:14
volumes:
- '.:/src'
ports:
- 6609:6609
- 6601:6601
- 6006:6006
working_dir: /src
command: yarn start

View file

@ -1,37 +0,0 @@
# Roleypoly Developer Guide
If you would like to help build Roleypoly, this guide will help get you started.
## Prerequisites
- Node.js 14+ & Yarn
- Wrangler CLI
- (Optional): Terraform 0.14+
- (Optional): Go 1.15+
## What things are built with
- **Backend/API**
- Node.js & Typescript
- Cloudflare Workers
- **Frontend**
- Next.js & React & Typescript
- Storybooks
- Homegrown Atomic Design System
- **Discord Bot**
- Go
- Google Cloud Run
- **CI/CD**
- GitHub Actions
- Terraform
## How does stuff fit together
As for infrastructure:
- CI/CD process deploys all pieces.
- Discord Bot is deployed on a Google Cloud VM
- Backend is deployed via a Cloudflare Worker
- UI is deployed via Google Cloud Run
Biggest thing to note: this "discord bot" is an optional piece of the system, and should always remain as such. Giving it responsibility has actual engineering and dollar cost.

View file

@ -1,84 +0,0 @@
# Roleypoly Infrastructure
## Ring 0
Any of these missing is a service outage.
### Backend
Backend is a Cloudflare Worker deployment. Edge computing, fuck yeah!
Hosts:
- `api-${env}.roleypoly.com/*`
- ex. for stage: `api-stage.roleypoly.com/*`
- `api.roleypoly.com/*` (only in prod)
It uses 3 KV namespaces per environment:
- Sessions
- Store of data and tokens mapped to sessions
- All data subject to 6 hour TTL
- GuildData
- Store of guild data, e.g. categories, etc
- All data is permanent (maybe doubly persisted to Firestore)
- Guilds
- Cache of Discord guild + guild member data
- All data subject to a 5 minute TTL
### App UI
The Next.js server is a docker container hosted on Google Cloud Run in multiple regions with a load balancer. Region focuses knowing traffic: US (x3), EU (x2), AP (x3). There is a running maximum of 10 containers per region, and a minimum of 0.
Regions:
- `us-east4` (South Carolina)
- `us-central1` (Iowa)
- `us-west1` (Oregon)
- `europe-west2` (London)
- `europe-west3` (Frankfurt)
- `australia-southeast1` (Sydney)
- `asia-northeast1` (Tokyo)
- `asia-southeast1` (Singapore)
Staging is only deployed to `us-east4`.
Hosts:
- `web-${env}.roleypoly.com`
- ex. for stage: `web-stage.roleypoly.com`
- `roleypoly.com` (only in prod, after release)
- `next.roleypoly.com` (only in prod, pre-release)
- `beta.roleypoly.com` (only in stage)
## Ring 1
Ephemeral services that can be tolerably lost, and would not be considered an outage, only degraded.
### v1 Migration Service
Hosted on v1 infrastructure, e.g. DigitalOcean. It is a connector and JSON API to the old v1 PgSQL data.
Host: `migration.v1.roleypoly.com` (Locked down to specific authorization tokens)
Sunset: Roleypoly Next + 3 months
### Bot (Mention Responder)
Bot doesn't do much, it's on a modestly sized Compute Engine VM using container hosting. It has no access to anything related to the real deployment of Roleypoly :)
Region: `us-east4` (South Carolina)
## Ring 2
Not end user applications. These will never be considered an "outage" if they are lost.
### Design System Storybook
Design system is a Vercel deployment
Host: `ui.roleypoly.com`

View file

@ -1,127 +0,0 @@
# User Stories
Loose doc defining end-to-end functionality for Roleypoly. Each slice has a cloud function or bot handler attached.
Legend
- (Hot) - Denotes cachable data stored for a short time, 2 minutes.
- Typical use cases: User roles; anything volatile.
- (Warm) - Denotes cachable data stored for a medium time, 10 minutes.
- Typical use cases: Guild roles; anything unlikely to change, but not painful to query.
- (Cold) - Denotes cachable data stored for a long time, 1 hour.
- Typical use cases: Guild lists for a session; anything very unlikely to change, and commonly used.
- (Permanent) - Denotes data that is permanent.
- Typical use cases: Guild customization data (categories, message)
- (Sec) - Security-minded data. Should never reach end-users.
- Typical use cases: Access tokens
## Primary
### Logged-in Index
As a user, I'd like to see all the servers I am in that can be used with the app.
- Type: Function
- Auth Level: User
- Flow Type: JSON API
- Data:
- User Current Guilds (Cold)
### Server Role Picker View
As a user, I'd like to see all of the roles I can select in a previously set up server.
- Type: Function
- Auth Level: User
- Flow Type: JSON API
- Data:
- User Current Guilds (Cold)
- Guild Roles (Warm)
- User Roles (Hot)
- Guild Customization (Cold) (Permanent)
### Server Role Picker Action
As a user, I'd like to select roles that have been selected in the role picker view for a server.
- Type: Function
- Auth Level: User
- Flow Type: JSON API
- Data:
- User Current Guilds (Cold)
- Guild Roles (Warm)
- User Roles (Hot)
### Server Editor View
As an admin, I'd like to see all of the settings and options for a server.
- Type: Function
- Auth Level: Admin
- Flow Type: JSON API
- Data:
- User Current Guilds (Cold)
- Guild Roles (Warm)
- Guild Customization (Cold) (Permanent)
### Server Editor Action
As an admin, I'd like to save settings and options that I have set within the editor view.
- Type: Function
- Auth Level: Admin
- Flow Type: JSON API
- Data:
- User Current Guilds (Cold)
- Guild Roles (Warm)
- Guild Customization (Cold) (Permanent)
### Session Pre-warming
As a user, I'd like to warm the cache with my current guild list after I log in.
- Type: Function
- Auth Level: User
- Flow Type: Bounces
- Data:
- User Current Guilds (Cold)
### Login
As a guest, I'd like to login with Discord so I can be authenticated as a user.
- Type: Function
- Auth Level: Guest
- Flow Type: OAuth, Bounces
- Data:
- Access Tokens (Sec)
### Logout
As a user, I'd like to revoke my authentication details.
- Type: Function
- Auth Level: User
- Flow Type: OAuth
- Data:
- Access Tokens (Sec)
### Bot Mention
As a discord user, I'd like to mention Roleypoly's bot account to get a link to my editor view.
- Type: Bot Responder
- Auth Level: N/A
- Flow Type: Command
- Data:
- None
### Bot Join
As a discord server admin, I'd like to follow the flow for adding Roleypoly to my server.
- Type: Function
- Auth Level: Guest
- Flow Type: OAuth
- Data:
- None

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module github.com/roleypoly/roleypoly
go 1.15
require (
github.com/bwmarrin/discordgo v0.22.0
github.com/dghubble/trie v0.0.0-20200716043226-5a94efb202d5
github.com/facebook/ent v0.4.3
github.com/google/go-github/v32 v32.1.0
github.com/joho/godotenv v1.3.0
github.com/julienschmidt/httprouter v1.3.0
github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df
github.com/segmentio/ksuid v1.0.3
go.uber.org/fx v1.13.1
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
k8s.io/klog v1.0.0
)

260
go.sum Normal file
View file

@ -0,0 +1,260 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bwmarrin/discordgo v0.22.0 h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM=
github.com/bwmarrin/discordgo v0.22.0/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dghubble/trie v0.0.0-20200716043226-5a94efb202d5 h1:euE/xPG0HIg6XiNXYrAHxX9aVwD1gw/yM2kptLOOj6k=
github.com/dghubble/trie v0.0.0-20200716043226-5a94efb202d5/go.mod h1:xNBeoT4V92/aNvuC3IJ2g59uxuKP4/kzvkpoHrb7v4A=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/facebook/ent v0.4.3 h1:ds9HENceKzpGBgCRlkZNq6TqBIegwKcF3e5reuV9Z0M=
github.com/facebook/ent v0.4.3/go.mod h1:4e/LKv3FFjj/867jPJYCxycZg0aGeEIgkiQ8jv2j6iQ=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-bindata/go-bindata v1.0.1-0.20190711162640-ee3c2418e368/go.mod h1:7xCgX1lzlrXPHkfvn3EhumqHkmSlzt8at9q7v0ax19c=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-sql-driver/mysql v1.5.1-0.20200311113236-681ffa848bae/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df h1:Y2o9fEOoAYjCw8IDyxUVaBq44AUbOLyPnYSPpM6Ef3M=
github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df/go.mod h1:lOfqvGl1HcXws86Sczusw1DyV5d0KHPtTTtdjneekto=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/ksuid v1.0.3 h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY=
github.com/segmentio/ksuid v1.0.3/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/dig v1.10.0 h1:yLmDDj9/zuDjv3gz8GQGviXMs9TfysIUMUilCpgzUJY=
go.uber.org/dig v1.10.0/go.mod h1:X34SnWGr8Fyla9zQNO2GSO2D+TIuqB14OS8JhYocIyw=
go.uber.org/fx v1.13.1 h1:CFNTr1oin5OJ0VCZ8EycL3wzF29Jz2g0xe55RFsf2a4=
go.uber.org/fx v1.13.1/go.mod h1:bREWhavnedxpJeTq9pQT53BbvwhUv7TcpsOqcH4a+3w=
go.uber.org/goleak v0.10.0 h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=
go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=
go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191030062658-86caa796c7ab/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191114200427-caa0b0f7d508/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200904185747-39188db58858 h1:xLt+iB5ksWcZVxqc+g9K41ZHy+6MKWfXCDsjSThnsPA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=

View file

@ -1,8 +0,0 @@
FROM mcr.microsoft.com/vscode/devcontainers/go:1.15
# Install Node.js
ARG NODE_VERSION="lts/*"
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"
# Install Wrangler
RUN su vscode -c "npm install -g wrangler"

12
hack/gazelle.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/sh
cd `dirname $(realpath $0)`
bazel run //:gazelle
bazel run //:gazelle -- update-repos -from_file=./go.mod --to_macro=deps.bzl%go_repositories -prune=true
sleep 0.5
echo "Fixing deps.bzl..."
head -n2 ../deps.bzl > ../deps.bzl~
tail -n+3 ../deps.bzl | sed '/^$/d' >> ../deps.bzl~
mv ../deps.bzl~ ../deps.bzl

7
hack/generate.go Normal file
View file

@ -0,0 +1,7 @@
package hacknotused
//go:generate sh gazelle.sh
func noop() {
}

View file

@ -1,4 +0,0 @@
import enableHooks from 'jest-react-hooks-shallow';
// pass an instance of jest to `enableHooks()`
enableHooks(jest);

4
hack/workspace_status.sh Executable file
View file

@ -0,0 +1,4 @@
#!/bin/bash
echo "STABLE_GIT_COMMIT $(git rev-parse --short HEAD)"
echo "STABLE_GIT_BRANCH $(git rev-parse --abbrev-ref HEAD)"
echo "BUILD_DATE $(date -Iseconds)"

View file

@ -2,76 +2,28 @@
"name": "roleypoly",
"version": "1.0.0",
"description": "https://roleypoly.com",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/roleypoly/roleypoly.git"
},
"homepage": "https://github.com/roleypoly/roleypoly#readme",
"author": "Katalina Okano <git@kat.cafe>",
"license": "MIT",
"bugs": {
"url": "https://github.com/roleypoly/roleypoly/issues"
},
"author": "Katalina Okano <git@kat.cafe>",
"license": "MIT",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build": "run-p -c build:*",
"build:api": "yarn workspace @roleypoly/api run build",
"build:design-system": "yarn workspace @roleypoly/design-system run build",
"build:web": "yarn workspace @roleypoly/web run build",
"create-component": "yarn workspace @roleypoly/design-system run create-component",
"lint": "run-p -c lint:* --",
"lint:eslint": "eslint",
"lint:prettier": "cross-env prettier -c '**/*.{ts,tsx,css,yml,yaml,md,json,js,jsx,sh,gitignore,mdx,Dockerfile}'",
"lint:stylelint": "cross-env stylelint 'packages/{web,design-system}/**/*.{ts,tsx}'",
"lint:terraform": "terraform fmt -check -recursive",
"lint:types": "tsc --noEmit",
"lint:types-api": "yarn workspace @roleypoly/api run lint:types",
"postinstall": "is-ci || husky install",
"start": "run-p -c start:*",
"start:api": "yarn workspace @roleypoly/api start",
"start:bot": "yarn workspace @roleypoly/bot start",
"start:design-system": "yarn workspace @roleypoly/design-system start",
"start:web": "yarn workspace @roleypoly/web start",
"test": "run-p -c test:* --",
"test:api": "yarn workspace @roleypoly/api run test",
"test:design-system": "yarn workspace @roleypoly/design-system run test",
"test:misc-utils": "yarn workspace @roleypoly/misc-utils run test",
"test:web": "yarn workspace @roleypoly/web run test"
"homepage": "https://github.com/roleypoly/roleypoly#readme",
"dependencies": {
"@improbable-eng/grpc-web": "0.13.0",
"google-protobuf": "3.13.0"
},
"dependencies": {},
"devDependencies": {
"@stylelint/postcss-css-in-js": "^0.37.2",
"husky": "^7.0.4",
"is-ci": "^3.0.1",
"jest-react-hooks-shallow": "^1.5.1",
"lint-staged": "^12.3.2",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.5",
"postcss-syntax": "^0.36.2",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^2.3.4",
"prettier-plugin-pkg": "^0.11.1",
"prettier-plugin-sh": "^0.8.1",
"stylelint": "^14.3.0",
"stylelint-config-recommended": "^6.0.0",
"stylelint-config-styled-components": "^0.1.1",
"typescript": "^4.5.5"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"prettier --write"
],
"*.{json,Dockerfile,sh,md,env,mdx,yml,html}": [
"prettier --write"
],
".*/*.{json,Dockerfile,sh,md,env,mdx,yml,html}": [
"prettier --write"
],
".husky/pre-commit": [
"prettier --write"
]
"@bazel/typescript": "^2.2.0",
"prettier": "^2.1.1",
"typescript": "^4.0.2",
"@roleypoly/ts-protoc-gen": "^1.0.1-promises.1",
"@types/google-protobuf": "3.7.3"
}
}

View file

@ -1,2 +0,0 @@
dist
.mf

View file

@ -1 +0,0 @@
17

View file

@ -1,252 +0,0 @@
import { uiPublicURI } from '@roleypoly/api/utils/config';
import {
Category,
DiscordUser,
Embed,
GuildData,
GuildDataUpdate,
GuildSlug,
WebhookValidationStatus,
} from '@roleypoly/types';
import { userAgent } from '@roleypoly/worker-utils';
import deepEqual from 'deep-equal';
import { sortBy, uniq } from 'lodash';
type WebhookPayload = {
username: string;
avatar_url: string;
embeds: Embed[];
provider: {
name: string;
url: string;
};
};
type ChangeHandler = (
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
newValue: GuildData[keyof GuildDataUpdate]
) => Embed[];
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
message: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x453e3d,
fields: [
{
name: 'Old Message',
value: oldValue as string,
inline: false,
},
{
name: 'New Message',
value: newValue as string,
inline: false,
},
],
title: `Server message was updated...`,
},
],
auditLogWebhook: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x5d5352,
fields: [
{
name: 'Old Webhook ID',
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
inline: false,
},
{
name: 'New Webhook ID',
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
inline: false,
},
],
title: `Audit Log webhook URL was changed...`,
},
],
categories: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Categories',
value: getChangedCategories(
oldValue as Category[],
newValue as Category[]
).join('\n'),
inline: false,
},
],
title: `Categories were changed...`,
},
],
accessControl: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Access Control',
value: getChangedAccessControl(
oldValue as GuildDataUpdate['accessControl'],
newValue as GuildDataUpdate['accessControl']
).join('\n'),
inline: false,
},
],
title: `Access Control was changed...`,
},
],
};
export const sendAuditLog = async (
guild: GuildData,
guildUpdate: GuildDataUpdate,
user: DiscordUser
) => {
const auditLogWebhooks = uniq([
guild.auditLogWebhook || '',
guildUpdate.auditLogWebhook || '',
]).filter((webhook) => webhook !== '');
if (auditLogWebhooks.length === 0) {
return;
}
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
const webhookPayload: WebhookPayload = {
username: 'Roleypoly (Audit Log)',
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
embeds: [
{
fields: [],
timestamp: new Date().toISOString(),
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
color: 0x332d2d,
author: {
name: user.username,
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
},
},
],
provider: {
name: 'Roleypoly',
url: uiPublicURI,
},
};
for (let key of keys) {
if (deepEqual(guildUpdate[key], guild[key])) {
continue;
}
const handler = changeHandlers[key];
if (!handler) {
continue;
}
const changeFields = handler(guild[key], guildUpdate[key]);
webhookPayload.embeds.push(...changeFields);
}
if (webhookPayload.embeds.length === 1) {
// No changes, don't bother sending
return;
}
// Colors are in order already, so use them to order the embeds.
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
const doWebhook = (webhook: string) =>
fetch(webhook, {
method: 'POST',
body: JSON.stringify(webhookPayload),
headers: {
'content-type': 'application/json',
'user-agent': userAgent,
},
});
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
};
export const validateAuditLogWebhook = async (
guild: GuildSlug,
webhook: string | null
): Promise<WebhookValidationStatus> => {
if (!webhook) {
return WebhookValidationStatus.NoneSet;
}
const url = new URL(webhook);
if (
url.hostname !== 'discord.com' ||
url.protocol !== 'https:' ||
url.pathname.startsWith('api/webhooks/')
) {
return WebhookValidationStatus.NotDiscordURL;
}
const response = await fetch(webhook, { method: 'GET' });
if (response.status !== 200) {
return WebhookValidationStatus.DoesNotExist;
}
const webhookData = await response.json();
if (webhookData.guild_id !== guild.id) {
return WebhookValidationStatus.NotSameGuild;
}
return WebhookValidationStatus.Ok;
};
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
const addedCategories = newCategories.filter(
(c) => !oldCategories.find((o) => o.id === c.id)
);
const removedCategories = oldCategories.filter(
(c) => !newCategories.find((o) => o.id === c.id)
);
const changedCategories = newCategories.filter(
(c) =>
oldCategories.find((o) => o.id === c.id) &&
!deepEqual(
oldCategories.find((o) => o.id === c.id),
newCategories.find((o) => o.id === c.id)
)
);
return [
...addedCategories.map((c) => ` **Added** ${c.name}`),
...removedCategories.map((c) => ` **Removed** ${c.name}`),
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
];
};
const getChangedAccessControl = (
oldAccessControl: GuildDataUpdate['accessControl'],
newAccessControl: GuildDataUpdate['accessControl']
) => {
const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending;
return [
`✅ Allowed roles: ${
newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*`
}`,
`❌ Blocked roles: ${
newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*`
}`,
...(pendingChanged
? [
`🔧 Pending/Welcome Screening users are ${
newAccessControl.blockPending ? 'blocked ❌' : 'allowed ✔'
}`,
]
: []),
];
};

View file

@ -1,104 +0,0 @@
import { CategoryType, Member, RoleSafety } from '@roleypoly/types';
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
import { difference, keyBy } from 'lodash';
import { interactionsEndpoint } from '../utils/api-tools';
import { botToken } from '../utils/config';
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '../utils/guild';
import { conflict, invalid, notAuthenticated, notFound, ok } from '../utils/responses';
export const InteractionsPickRole = interactionsEndpoint(
async (request: Request): Promise<Response> => {
const mode = request.method === 'PUT' ? 'add' : 'remove';
const reqURL = new URL(request.url);
const [, , guildID, userID, roleID] = reqURL.pathname.split('/');
if (!guildID || !userID || !roleID) {
return invalid();
}
const guildP = getGuild(guildID);
const guildDataP = getGuildData(guildID);
const guildMemberP = getGuildMember(
{ serverID: guildID, userID },
{ skipCachePull: true }
);
const [guild, guildData, guildMember] = await Promise.all([
guildP,
guildDataP,
guildMemberP,
]);
if (!guild || !guildData || !guildMember) {
return notFound();
}
let memberRoles = guildMember.roles;
if (
(mode === 'add' && memberRoles.includes(roleID)) ||
(mode !== 'add' && !memberRoles.includes(roleID))
) {
return conflict();
}
const roleMap = keyBy(guild.roles, 'id');
const category = guildData.categories.find((category) =>
category.roles.includes(roleID)
);
// No category? illegal.
if (!category) {
return notFound();
}
// Category is hidden, this is illegal
if (category.hidden) {
return notFound();
}
// Role is unsafe, super illegal.
if (roleMap[roleID].safety !== RoleSafety.Safe) {
return notAuthenticated();
}
// In add mode, if the category is a single-mode, remove the other roles in the category.
if (mode === 'add' && category.type === CategoryType.Single) {
memberRoles = difference(memberRoles, category.roles);
}
if (mode === 'add') {
memberRoles = [...memberRoles, roleID];
} else {
memberRoles = memberRoles.filter((id) => id !== roleID);
}
const patchMemberRoles = await discordFetch<Member>(
`/guilds/${guildID}/members/${userID}`,
botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via slash command`,
},
body: JSON.stringify({
roles: memberRoles,
}),
}
);
if (!patchMemberRoles) {
return respond({ error: 'discord rejected the request' }, { status: 500 });
}
await updateGuildMember({ serverID: guildID, userID });
return ok();
}
);

View file

@ -1,33 +0,0 @@
import { Category, CategorySlug } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils';
import { interactionsEndpoint } from '../utils/api-tools';
import { getGuildData } from '../utils/guild';
import { notFound } from '../utils/responses';
export const InteractionsPickableRoles = interactionsEndpoint(
async (request: Request): Promise<Response> => {
const reqURL = new URL(request.url);
const [, , serverID] = reqURL.pathname.split('/');
const guildData = await getGuildData(serverID);
if (!guildData) {
return notFound();
}
const roleMap: Record<Category['name'], CategorySlug> = {};
for (let category of guildData.categories) {
if (category.hidden) {
continue;
}
// TODO: role safety?
roleMap[category.name] = {
roles: category.roles,
type: category.type,
};
}
return respond(roleMap);
}
);

View file

@ -1,16 +0,0 @@
import {
InteractionCallbackType,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld = async (
interaction: InteractionRequestCommand
): Promise<InteractionResponse> => {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
},
};
};

View file

@ -1,86 +0,0 @@
import { selectRole } from '@roleypoly/interactions/utils/api';
import {
asyncPreflightEphemeral,
asyncResponse,
} from '@roleypoly/interactions/utils/interactions';
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import {
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const pickRole = (mode: 'add' | 'remove') =>
asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
if (!interaction.guild_id) {
return mustBeInGuild();
}
const userID = interaction.member?.user?.id;
if (!userID) {
return mustBeInGuild();
}
const roleID = interaction.data.options?.find(
(option) => option.name === 'role'
)?.value;
if (!roleID) {
return invalid();
}
const code = await selectRole(mode, interaction.guild_id, userID, roleID);
if (code === 409) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
if (code === 404) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: <@&${roleID}> isn't pickable.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
if (code === 403) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: <@&${roleID}> has unsafe permissions.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
if (code !== 200) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: Something went wrong, please try again later.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:white_check_mark: You ${
mode === 'add' ? 'got' : 'removed'
} the role: <@&${roleID}>`,
flags: InteractionFlags.EPHEMERAL,
},
};
},
asyncPreflightEphemeral
);

View file

@ -1,63 +0,0 @@
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import {
asyncPreflightEphemeral,
asyncResponse,
} from '@roleypoly/interactions/utils/interactions';
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import {
CategoryType,
Embed,
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const pickableRoles = asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
if (!interaction.guild_id) {
return mustBeInGuild();
}
const pickableRoles = await getPickableRoles(interaction.guild_id);
const embed: Embed = {
color: 0xab9b9a,
fields: [],
title: 'You can pick any of these roles with /pick-role',
};
for (let categoryName in pickableRoles) {
const { roles, type } = pickableRoles[categoryName];
embed.fields.push({
name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`,
value: roles.map((role) => `<@&${role}>`).join('\n'),
inline: true,
});
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [embed],
flags: InteractionFlags.EPHEMERAL,
components: [
{
type: 1,
components: [
// Link to Roleypoly
{
type: 2,
label: 'Pick roles on your browser',
url: `${uiPublicURI}/s/${interaction.guild_id}`,
style: 5,
},
],
},
],
},
};
},
asyncPreflightEphemeral
);

View file

@ -1,56 +0,0 @@
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import {
Embed,
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const roleypoly = async (
interaction: InteractionRequestCommand
): Promise<InteractionResponse> => {
if (interaction.guild_id) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
color: 0x453e3d,
title: `:beginner: Hey there, ${
interaction.member?.nick || interaction.member?.user?.username || 'friend'
}!`,
description: `Try these slash commands, or pick roles from your browser!`,
fields: [
{ name: 'See all the roles', value: '/pickable-roles' },
{ name: 'Pick a role', value: '/pick-role' },
{ name: 'Remove a role', value: '/remove-role' },
],
} as Embed,
],
components: [
{
type: 1,
components: [
// Link to Roleypoly
{
type: 2,
label: `Pick roles on ${new URL(uiPublicURI).hostname}`,
url: `${uiPublicURI}/s/${interaction.guild_id}`,
style: 5,
},
],
},
],
flags: InteractionFlags.EPHEMERAL,
},
};
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:beginner: Hey! I don't know what server you're in, so check out ${uiPublicURI}`,
},
};
};

View file

@ -1,11 +0,0 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
name: 'api',
testEnvironment: 'miniflare',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.test.json',
useESM: true,
},
},
};

View file

@ -1,31 +0,0 @@
{
"name": "@roleypoly/api",
"version": "0.1.0",
"license": "MIT",
"main": "./src/index.ts",
"scripts": {
"build": "yarn build:dev --minify",
"build:dev": "esbuild --bundle --sourcemap --platform=node --format=esm --outdir=dist --out-extension:.js=.mjs ./src/index.ts",
"lint:types": "tsc --noEmit",
"posttest": "rm .env",
"pretest": "cp ../../.env.example .env && yarn build",
"start": "miniflare --watch --debug",
"test": "jest"
},
"dependencies": {},
"devDependencies": {
"@cloudflare/workers-types": "^3.3.1",
"@roleypoly/misc-utils": "*",
"@roleypoly/types": "*",
"@types/node": "^17.0.13",
"esbuild": "^0.14.16",
"itty-router": "^2.4.10",
"jest-environment-miniflare": "^2.2.0",
"lodash": "^4.17.21",
"miniflare": "^2.2.0",
"normalize-url": "^4.5.1",
"ts-jest": "^27.1.3",
"tweetnacl": "^1.0.3",
"ulid-workers": "^2.1.0"
}
}

View file

@ -1,5 +0,0 @@
it('works', () => {
expect(true).toBeTruthy();
});
export {};

View file

@ -1 +0,0 @@
export {};

View file

@ -1,208 +0,0 @@
jest.mock('../utils/discord');
import { CategoryType, Features, Guild, GuildData, RoleSafety } from '@roleypoly/types';
import { APIGuild, discordFetch } from '../utils/discord';
import { configContext } from '../utils/testHelpers';
import { getGuild, getGuildData, getGuildMember, getPickableRoles } from './getters';
const mockDiscordFetch = discordFetch as jest.Mock;
beforeEach(() => {
mockDiscordFetch.mockReset();
});
describe('getGuild', () => {
it('gets a guild from discord', async () => {
const [config] = configContext();
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [],
};
mockDiscordFetch.mockReturnValue(guild);
const result = await getGuild(config, '123');
expect(result).toMatchObject(guild);
});
it('gets a guild from cache automatically', async () => {
const [config] = configContext();
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [],
};
await config.kv.guilds.put('guild/123', guild, config.retention.guild);
mockDiscordFetch.mockReturnValue({ ...guild, name: 'test2' });
const result = await getGuild(config, '123');
expect(result).toMatchObject(guild);
expect(result!.name).toBe('test');
});
});
describe('getGuildData', () => {
it('gets guild data from store', async () => {
const [config] = configContext();
const guildData: GuildData = {
id: '123',
message: 'Hello world!',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
await config.kv.guildData.put('123', guildData);
const result = await getGuildData(config, '123');
expect(result).toMatchObject(guildData);
});
it('adds fields that are missing from the stored data', async () => {
const [config] = configContext();
const guildData: Partial<GuildData> = {
id: '123',
message: 'Hello world!',
categories: [],
features: Features.None,
};
await config.kv.guildData.put('123', guildData);
const result = await getGuildData(config, '123');
expect(result).toMatchObject({
...guildData,
auditLogWebhook: null,
accessControl: expect.any(Object),
});
});
});
describe('getGuildMember', () => {
it('gets a member from discord', async () => {
const [config] = configContext();
const member = {
roles: [],
pending: false,
nick: 'test',
};
mockDiscordFetch.mockReturnValue(member);
const result = await getGuildMember(config, '123', '123');
expect(result).toMatchObject(member);
});
it('gets a member from cache automatically', async () => {
const [config] = configContext();
const member = {
roles: [],
pending: false,
nick: 'test2',
};
await config.kv.guilds.put('member/123/123', member, config.retention.guild);
mockDiscordFetch.mockReturnValue({ ...member, nick: 'test' });
const result = await getGuildMember(config, '123', '123');
expect(result).toMatchObject(member);
expect(result!.nick).toBe('test2');
});
});
describe('getPickableRoles', () => {
it('returns all pickable roles for a given guild', async () => {
const guildData: GuildData = {
id: '123',
message: 'Hello world!',
categories: [
{
id: '123',
name: 'test',
position: 0,
roles: ['role-1', 'role-2', 'role-unsafe'],
hidden: false,
type: CategoryType.Multi,
},
{
id: '123',
name: 'test',
position: 0,
roles: ['role-3', 'role-4'],
hidden: true,
type: CategoryType.Multi,
},
],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
const guild: Guild = {
id: '123',
name: 'test',
icon: '',
roles: [
{
id: 'role-1',
name: 'test',
position: 0,
managed: false,
color: 0,
safety: RoleSafety.Safe,
permissions: '0',
},
{
id: 'role-3',
name: 'test',
position: 0,
managed: false,
color: 0,
safety: RoleSafety.Safe,
permissions: '0',
},
{
id: 'role-unsafe',
name: 'test',
position: 0,
managed: false,
color: 0,
safety: RoleSafety.DangerousPermissions,
permissions: '0',
},
],
};
const result = getPickableRoles(guildData, guild);
expect(result).toMatchObject([
{
category: guildData.categories[0],
roles: [guild.roles[0]],
},
]);
});
});

View file

@ -1,196 +0,0 @@
import { Config } from '@roleypoly/api/src/utils/config';
import {
APIGuild,
APIMember,
APIRole,
AuthType,
discordFetch,
getHighestRole,
} from '@roleypoly/api/src/utils/discord';
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
import {
Category,
Features,
Guild,
GuildData,
Member,
OwnRoleInfo,
Role,
RoleSafety,
} from '@roleypoly/types';
export const getGuild = async (
config: Config,
id: string,
forceMiss?: boolean
): Promise<(Guild & OwnRoleInfo) | null> =>
config.kv.guilds.cacheThrough(
`guild/${id}`,
async () => {
const guildRaw = await discordFetch<APIGuild>(
`/guilds/${id}`,
config.botToken,
AuthType.Bot
);
if (!guildRaw) {
return null;
}
const botMemberRoles =
(await getGuildMember(config, id, config.botClientID))?.roles || [];
const highestRolePosition =
getHighestRole(
botMemberRoles
.map((r) => guildRaw.roles.find((r2) => r2.id === r))
.filter((x) => x !== undefined) as APIRole[]
)?.position || -1;
const roles = guildRaw.roles.map<Role>((role) => ({
id: role.id,
name: role.name,
color: role.color,
managed: role.managed,
position: role.position,
permissions: role.permissions,
safety: calculateRoleSafety(role, highestRolePosition),
}));
const guild: Guild & OwnRoleInfo = {
id,
name: guildRaw.name,
icon: guildRaw.icon,
roles,
highestRolePosition,
};
return guild;
},
config.retention.guild,
forceMiss
);
export const getGuildData = async (config: Config, id: string): Promise<GuildData> => {
const guildData = await config.kv.guildData.get<GuildData>(id);
const empty = {
id,
message: '',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
if (!guildData) {
await config.kv.guildData.put(id, empty);
return empty;
}
return {
...empty,
...guildData,
};
};
export const getGuildMember = async (
config: Config,
serverID: string,
userID: string,
forceMiss?: boolean,
overrideRetention?: number // allows for own-member to be cached as long as it's used.
): Promise<Member | null> =>
config.kv.guilds.cacheThrough(
`member/${serverID}/${userID}`,
async () => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
config.botToken,
AuthType.Bot
);
if (!discordMember) {
return null;
}
return {
guildid: serverID,
roles: discordMember.roles,
pending: discordMember.pending,
nick: discordMember.nick,
};
},
overrideRetention || config.retention.member,
forceMiss
);
export const updateGuildMember = async (
config: Config,
serverID: string,
member: APIMember
): Promise<void> => {
config.kv.guilds.put(
`members/${serverID}/${member.user.id}`,
{
guildid: serverID,
roles: member.roles,
pending: member.pending,
nick: member.nick,
},
config.retention.member
);
};
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
let safety = RoleSafety.Safe;
if (role.managed) {
safety |= RoleSafety.ManagedRole;
}
if (role.position > highestBotRolePosition) {
safety |= RoleSafety.HigherThanBot;
}
const permBigInt = BigInt(role.permissions);
if (
evaluatePermission(permBigInt, permissions.ADMINISTRATOR) ||
evaluatePermission(permBigInt, permissions.MANAGE_ROLES)
) {
safety |= RoleSafety.DangerousPermissions;
}
return safety;
};
export const getPickableRoles = (
guildData: GuildData,
guild: Guild
): { category: Category; roles: Role[] }[] => {
const pickableRoles: { category: Category; roles: Role[] }[] = [];
for (const category of guildData.categories) {
if (category.roles.length === 0 || category.hidden) {
continue;
}
const roles = category.roles
.map((roleID) => guild.roles.find((r) => r.id === roleID))
.filter((role) => role !== undefined && role.safety === RoleSafety.Safe) as Role[];
if (roles.length > 0) {
pickableRoles.push({
category,
roles,
});
}
}
console.log({ pickableRoles });
return pickableRoles;
};

View file

@ -1,78 +0,0 @@
import { Router } from 'itty-router';
import { json } from '../utils/response';
import { configContext, makeSession } from '../utils/testHelpers';
import { requireEditor } from './middleware';
describe('requireEditor', () => {
it('continues the request when user is an editor', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/${session.guilds[1].id}`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: session.guilds[1].id } }
);
expect(response.status).toBe(200);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('403s the request when user is not an editor', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/${session.guilds[0].id}`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: session.guilds[0].id } }
);
expect(response.status).toBe(403);
expect(testFn).not.toHaveBeenCalled();
});
it('404s the request when the guild isnt in session', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/invalid-session-id`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: 'invalid-session-id' } }
);
expect(response.status).toBe(404);
expect(testFn).not.toHaveBeenCalled();
});
});

View file

@ -1,47 +0,0 @@
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
import {
engineeringProblem,
forbidden,
notFound,
} from '@roleypoly/api/src/utils/response';
import { UserGuildPermissions } from '@roleypoly/types';
export const requireEditor: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (!context.params.guildId) {
return engineeringProblem('params not set up correctly');
}
if (!context.session) {
return engineeringProblem('middleware not set up correctly');
}
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
if (!guild) {
return notFound(); // 404 because we don't want enumeration of guilds
}
if (guild.permissionLevel === UserGuildPermissions.User) {
return forbidden();
}
};
export const requireMember: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (!context.params.guildId) {
return engineeringProblem('params not set up correctly');
}
if (!context.session) {
return engineeringProblem('middleware not set up correctly');
}
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
if (!guild) {
return notFound(); // 404 because we don't want enumeration of guilds
}
};

View file

@ -1,99 +0,0 @@
import { requireEditor, requireMember } from '@roleypoly/api/src/guilds/middleware';
import { authBot } from '@roleypoly/api/src/routes/auth/bot';
import { authCallback } from '@roleypoly/api/src/routes/auth/callback';
import { authSessionDelete } from '@roleypoly/api/src/routes/auth/delete-session';
import { authSession } from '@roleypoly/api/src/routes/auth/session';
import { guildsGuild } from '@roleypoly/api/src/routes/guilds/guild';
import { guildsCacheDelete } from '@roleypoly/api/src/routes/guilds/guild-cache-delete';
import { guildsRolesPut } from '@roleypoly/api/src/routes/guilds/guild-roles-put';
import { guildsGuildPatch } from '@roleypoly/api/src/routes/guilds/guilds-patch';
import { guildsSlug } from '@roleypoly/api/src/routes/guilds/slug';
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
import {
requireSession,
withAuthMode,
withSession,
} from '@roleypoly/api/src/sessions/middleware';
import { injectParams } from '@roleypoly/api/src/utils/request';
import { Router } from 'itty-router';
import { authBounce } from './routes/auth/bounce';
import { Environment, parseEnvironment } from './utils/config';
import { Context, RoleypolyHandler } from './utils/context';
import { corsHeaders, json, notFound, serverError } from './utils/response';
const router = Router();
router.all('*', withAuthMode);
router.get('/auth/bot', authBot);
router.get('/auth/bounce', authBounce);
router.get('/auth/callback', authCallback);
router.get('/auth/session', withSession, requireSession, authSession);
router.delete('/auth/session', withSession, requireSession, authSessionDelete);
const guildsCommon = [injectParams, withSession, requireSession, requireMember];
router.get('/guilds/:guildId', ...guildsCommon, guildsGuild);
router.patch('/guilds/:guildId', ...guildsCommon, requireEditor, guildsGuildPatch);
router.delete(
'/guilds/:guildId/cache',
...guildsCommon,
requireEditor,
guildsCacheDelete
);
router.put('/guilds/:guildId/roles', ...guildsCommon, guildsRolesPut);
router.get('/guilds/:guildId/slug', injectParams, withSession, guildsSlug);
router.post('/interactions', handleInteraction);
router.get('/', ((request: Request, { config }: Context) =>
json({
__warning: '🦊',
this: 'is',
a: 'fox-based',
web: 'application',
please: 'be',
aware: 'of',
your: 'surroundings',
warning__: '🦊',
meta: config.uiPublicURI,
version: 2,
})) as RoleypolyHandler);
router.options('*', (request: Request) => {
return new Response(null, {
headers: {
...corsHeaders,
},
});
});
router.all('/*', notFound);
const scrubURL = (urlStr: string) => {
const url = new URL(urlStr);
url.searchParams.delete('code');
url.searchParams.delete('state');
return url.toString();
};
export default {
async fetch(request: Request, env: Environment, event: Context['fetchContext']) {
const config = parseEnvironment(env);
const context: Context = {
config,
fetchContext: {
waitUntil: event.waitUntil.bind(event),
},
authMode: {
type: 'anonymous',
},
params: {},
};
console.log(`${request.method} ${scrubURL(request.url)}`);
return router
.handle(request, context)
.catch((e: Error) => (!e ? notFound() : serverError(e)));
},
};

View file

@ -1,25 +0,0 @@
import { makeRequest } from '../../utils/testHelpers';
describe('GET /auth/bot', () => {
it('redirects to a Discord OAuth bot flow url', async () => {
const response = await makeRequest('GET', '/auth/bot', undefined, {
BOT_CLIENT_ID: 'test123',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456'
);
});
it('redirects to a Discord OAuth bot flow url, forcing a guild when set', async () => {
const response = await makeRequest('GET', '/auth/bot?guild=123456', undefined, {
BOT_CLIENT_ID: 'test123',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456&guild_id=123456&disable_guild_select=true'
);
});
});

View file

@ -1,43 +0,0 @@
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { seeOther } from '@roleypoly/api/src/utils/response';
const validGuildID = /^[0-9]+$/;
type URLParams = {
clientID: string;
permissions: number;
guildID?: string;
scopes: string[];
};
const buildURL = (params: URLParams) => {
let url = `https://discord.com/api/oauth2/authorize?client_id=${
params.clientID
}&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`;
if (params.guildID) {
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
}
return url;
};
export const authBot: RoleypolyHandler = (
request: Request,
{ config }: Context
): Response => {
let guildID = new URL(request.url).searchParams.get('guild') || '';
if (guildID && !validGuildID.test(guildID)) {
guildID = '';
}
return seeOther(
buildURL({
clientID: config.botClientID,
permissions: 268435456, // Send messages + manage roles
guildID,
scopes: ['bot', 'applications.commands'],
})
);
};

View file

@ -1,54 +0,0 @@
import { StateSession } from '@roleypoly/types';
import { getBindings, makeRequest } from '../../utils/testHelpers';
describe('GET /auth/bounce', () => {
it('should return a redirect to Discord OAuth', async () => {
const response = await makeRequest('GET', '/auth/bounce', undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://test.local/',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&response_type=code&scope=identify%20guilds&redirect_uri=http%3A%2F%2Ftest.local%2Fauth%2Fcallback&state='
);
});
it('should store a state-session', async () => {
const response = await makeRequest('GET', '/auth/bounce', undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://test.local/',
});
expect(response.status).toBe(303);
const url = new URL(response.headers.get('Location') || '');
const state = url.searchParams.get('state');
const environment = getBindings();
const session = await environment.KV_SESSIONS.get(`state_${state}`, 'json');
expect(session).not.toBeUndefined();
});
test.each([
['http://web.test.local', 'http://web.test.local', 'http://web.test.local'],
['http://*.test.local', 'http://web.test.local', 'http://web.test.local'],
['http://other.test.local', 'http://web.test.local', undefined],
])(
'should process callback hosts when set to %s',
async (allowlist, input, expected) => {
const response = await makeRequest('GET', `/auth/bounce?cbh=${input}`, undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://api.test.local',
ALLOWED_CALLBACK_HOSTS: allowlist,
});
expect(response.status).toBe(303);
const url = new URL(response.headers.get('Location') || '');
const state = url.searchParams.get('state');
const environment = getBindings();
const session = (await environment.KV_SESSIONS.get(`state_${state}`, 'json')) as {
data: StateSession;
};
expect(session).not.toBeUndefined();
expect(session?.data.callbackHost).toBe(expected);
}
);
});

View file

@ -1,64 +0,0 @@
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
import { Config } from '@roleypoly/api/src/utils/config';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { getQuery } from '@roleypoly/api/src/utils/request';
import { seeOther } from '@roleypoly/api/src/utils/response';
import { StateSession } from '@roleypoly/types';
type URLParams = {
clientID: string;
redirectURI: string;
state: string;
};
export const buildURL = (params: URLParams) =>
`https://discord.com/api/oauth2/authorize?client_id=${
params.clientID
}&response_type=code&scope=identify%20guilds&redirect_uri=${encodeURIComponent(
params.redirectURI
)}&state=${params.state}`;
const hostMatch = (a: string, b: string): boolean => {
const aURL = new URL(a);
const bURL = new URL(b);
return aURL.host === bURL.host && aURL.protocol === bURL.protocol;
};
const wildcardMatch = (wildcard: string, host: string): boolean => {
const aURL = new URL(wildcard);
const bURL = new URL(host);
const regex = new RegExp(aURL.hostname.replace('*', '[a-z0-9-]+'));
return regex.test(bURL.hostname);
};
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
return (
hostMatch(host, config.apiPublicURI) ||
config.allowedCallbackHosts.some((allowedHost) =>
allowedHost.includes('*')
? wildcardMatch(allowedHost, host)
: hostMatch(allowedHost, host)
)
);
};
export const authBounce: RoleypolyHandler = async (
request: Request,
{ config }: Context
) => {
const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
if (callbackHost && isAllowedCallbackHost(config, callbackHost)) {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(config, stateSessionData);
const redirectURI = `${config.apiPublicURI}/auth/callback`;
const clientID = config.botClientID;
return seeOther(buildURL({ state, redirectURI, clientID }));
};

View file

@ -1,93 +0,0 @@
jest.mock('../../utils/discord');
jest.mock('../../sessions/create');
import { createSession } from '../../sessions/create';
import { setupStateSession } from '../../sessions/state';
import { parseEnvironment } from '../../utils/config';
import { discordFetch } from '../../utils/discord';
import { getBindings, makeRequest } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockCreateSession = createSession as jest.Mock;
describe('GET /auth/callback', () => {
it('should ask Discord to trade code for tokens', async () => {
const env = getBindings();
const config = parseEnvironment(env);
const stateID = await setupStateSession(config, {});
const tokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
};
mockDiscordFetch.mockReturnValueOnce(tokens);
mockCreateSession.mockReturnValueOnce({
sessionID: 'test-session-id',
tokens,
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
});
const response = await makeRequest(
'GET',
`/auth/callback?state=${stateID}&code=1234`,
undefined,
{
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
}
);
expect(response.status).toBe(303);
expect(mockDiscordFetch).toBeCalledTimes(1);
expect(mockCreateSession).toBeCalledWith(expect.any(Object), tokens);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/new-session#/test-session-id'
);
});
it('will fail if state is invalid', async () => {
const response = await makeRequest(
'GET',
`/auth/callback?state=invalid-state&code=1234`,
undefined,
{
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
}
);
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/error/authFailure?extra=state invalid'
);
});
it('will fail if state is missing', async () => {
const response = await makeRequest('GET', `/auth/callback?code=1234`, undefined, {
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/error/authFailure?extra=state invalid'
);
});
});

View file

@ -1,77 +0,0 @@
import { isAllowedCallbackHost } from '@roleypoly/api/src/routes/auth/bounce';
import { createSession } from '@roleypoly/api/src/sessions/create';
import { getStateSession } from '@roleypoly/api/src/sessions/state';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { dateFromID } from '@roleypoly/api/src/utils/id';
import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request';
import { seeOther } from '@roleypoly/api/src/utils/response';
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
import normalizeUrl from 'normalize-url';
const authFailure = (uiPublicURI: string, extra?: string) =>
seeOther(uiPublicURI + `/error/authFailure${extra ? `?extra=${extra}` : ''}`);
export const authCallback: RoleypolyHandler = async (
request: Request,
{ config }: Context
) => {
let bounceBaseUrl = config.uiPublicURI;
const { state: stateValue, code } = getQuery(request);
if (stateValue === null) {
return authFailure('state missing');
}
try {
const stateTime = dateFromID(stateValue);
const stateExpiry = stateTime + 1000 * config.retention.session;
const currentTime = Date.now();
if (currentTime > stateExpiry) {
return authFailure('state expired');
}
const stateSession = await getStateSession<StateSession>(config, stateValue);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(config, stateSession.callbackHost)
) {
bounceBaseUrl = stateSession.callbackHost;
}
} catch (e) {
return authFailure(config.uiPublicURI, 'state invalid');
}
if (!code) {
return authFailure(config.uiPublicURI, 'code missing');
}
const response = await discordFetch<AuthTokenResponse>(
`/oauth2/token`,
'',
AuthType.None,
formDataRequest({
client_id: config.botClientID,
client_secret: config.botClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.apiPublicURI + '/auth/callback',
})
);
if (!response) {
return authFailure(config.uiPublicURI, 'code auth failure');
}
const session = await createSession(config, response);
if (!session) {
return authFailure(config.uiPublicURI, 'session setup failure');
}
const nextURL = normalizeUrl(
bounceBaseUrl + '/machinery/new-session/#/' + session.sessionID
);
return seeOther(nextURL);
};

View file

@ -1,63 +0,0 @@
jest.mock('../../utils/discord');
import { SessionData } from '@roleypoly/types';
import { parseEnvironment } from '../../utils/config';
import { AuthType, discordFetch } from '../../utils/discord';
import { formDataRequest } from '../../utils/request';
import { getBindings, makeRequest } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
describe('DELETE /auth/session', () => {
it('deletes the current user session when it is valid', async () => {
const config = parseEnvironment(getBindings());
const session: SessionData = {
sessionID: 'test-session-id',
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
tokens: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
},
};
await config.kv.sessions.put(session.sessionID, session);
mockDiscordFetch.mockReturnValue(
new Response(null, {
status: 200,
})
);
const response = await makeRequest('DELETE', '/auth/session', {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(204);
expect(await config.kv.sessions.get(session.sessionID)).toBeNull();
expect(mockDiscordFetch).toHaveBeenCalledWith(
'/oauth2/token/revoke',
'',
AuthType.None,
expect.objectContaining(
formDataRequest({
client_id: config.botClientID,
client_secret: config.botClientSecret,
token: session.tokens.access_token,
})
)
);
});
});

View file

@ -1,27 +0,0 @@
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { formDataRequest } from '@roleypoly/api/src/utils/request';
import { noContent } from '@roleypoly/api/src/utils/response';
export const authSessionDelete: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (!context.session) {
return noContent();
}
await discordFetch(
'/oauth2/token/revoke',
'',
AuthType.None,
formDataRequest({
client_id: context.config.botClientID,
client_secret: context.config.botClientSecret,
token: context.session.tokens.access_token,
})
);
await context.config.kv.sessions.delete(context.session.sessionID);
return noContent();
};

View file

@ -1,53 +0,0 @@
import { SessionData } from '@roleypoly/types';
import { parseEnvironment } from '../../utils/config';
import { getBindings, makeRequest } from '../../utils/testHelpers';
describe('GET /auth/session', () => {
it('fetches the current user session when it is valid', async () => {
const config = parseEnvironment(getBindings());
const session: SessionData = {
sessionID: 'test-session-id',
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
tokens: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
},
};
await config.kv.sessions.put(session.sessionID, session);
const response = await makeRequest('GET', '/auth/session', {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
sessionID: session.sessionID,
user: session.user,
guilds: session.guilds,
});
});
it('returns 401 when session is not valid', async () => {
const response = await makeRequest('GET', '/auth/session', {
headers: {
Authorization: `Bearer invalid-session-id`,
},
});
expect(response.status).toBe(401);
});
});

View file

@ -1,17 +0,0 @@
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { json, notFound } from '@roleypoly/api/src/utils/response';
export const authSession: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (context.session) {
return json({
user: context.session.user,
guilds: context.session.guilds,
sessionID: context.session.sessionID,
});
}
return notFound();
};

View file

@ -1,32 +0,0 @@
jest.mock('../../guilds/getters');
import { UserGuildPermissions } from '@roleypoly/types';
import { getGuild } from '../../guilds/getters';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
describe('DELETE /guilds/:id/cache', () => {
it('calls getGuilds and returns No Content', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Admin,
},
],
});
const response = await makeRequest('DELETE', `/guilds/123/cache`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(204);
expect(mockGetGuild).toHaveBeenCalledWith(expect.any(Object), '123', true);
});
});

View file

@ -1,12 +0,0 @@
import { getGuild } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { noContent } from '@roleypoly/api/src/utils/response';
export const guildsCacheDelete: RoleypolyHandler = async (
request: Request,
context: Context
) => {
await getGuild(context.config, context.params.guildId!, true);
return noContent();
};

View file

@ -1,370 +0,0 @@
jest.mock('../../guilds/getters');
jest.mock('../../utils/discord');
import {
CategoryType,
Features,
Guild,
GuildData,
Member,
OwnRoleInfo,
RoleSafety,
RoleUpdate,
TransactionType,
} from '@roleypoly/types';
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
import { AuthType, discordFetch } from '../../utils/discord';
import { json } from '../../utils/response';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockGetGuild = getGuild as jest.Mock;
const mockGetGuildMember = getGuildMember as jest.Mock;
const mockGetGuildData = getGuildData as jest.Mock;
beforeEach(() => {
jest.resetAllMocks();
doMock();
});
describe('PUT /guilds/:id/roles', () => {
it('adds member roles when called with valid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [{ id: 'role-2', action: TransactionType.Add }],
};
mockDiscordFetch.mockReturnValueOnce(
json({
roles: ['role-1', 'role-2'],
})
);
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: ['role-1', 'role-2'],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('removes member roles when called with valid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [{ id: 'role-1', action: TransactionType.Remove }],
};
mockDiscordFetch.mockReturnValueOnce(
json({
roles: [],
})
);
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: [],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('does not update roles when called only with invalid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
{ id: 'role-5-unsafe', action: TransactionType.Add }, // role is marked unsafe
],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(400);
expect(mockDiscordFetch).not.toHaveBeenCalled();
});
it('filters roles that are invalid while accepting ones that are valid', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
{ id: 'role-2', action: TransactionType.Add }, // role is in a hidden category
],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: ['role-1', 'role-2'],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('400s when no transactions are present', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(400);
expect(mockDiscordFetch).not.toHaveBeenCalled();
expect(mockGetGuild).not.toHaveBeenCalled();
expect(mockGetGuildData).not.toHaveBeenCalled();
expect(mockGetGuildMember).not.toHaveBeenCalled();
});
});
const doMock = () => {
const guild: Guild & OwnRoleInfo = {
id: '123',
name: 'test',
icon: 'test',
highestRolePosition: 0,
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-2',
name: 'Role 2',
color: 0,
position: 16,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-3',
name: 'Role 3',
color: 0,
position: 15,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-4',
name: 'Role 4',
color: 0,
position: 14,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-5-unsafe',
name: 'Role 5 (Unsafe)',
color: 0,
position: 14,
permissions: '',
managed: false,
safety: RoleSafety.DangerousPermissions,
},
],
};
const member: Member = {
roles: ['role-1'],
pending: false,
nick: '',
};
const guildData: GuildData = {
id: '123',
message: 'test',
categories: [
{
id: 'category-1',
name: 'Category 1',
position: 0,
hidden: false,
type: CategoryType.Multi,
roles: ['role-1', 'role-2'],
},
{
id: 'category-2',
name: 'Category 2',
position: 1,
hidden: true,
type: CategoryType.Multi,
roles: ['role-3'],
},
],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(member);
mockGetGuildData.mockReturnValue(guildData);
mockDiscordFetch.mockReturnValue(json({}));
};

View file

@ -1,160 +0,0 @@
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import {
engineeringProblem,
invalid,
json,
notFound,
serverError,
} from '@roleypoly/api/src/utils/response';
import {
difference,
isIdenticalArray,
keyBy,
union,
} from '@roleypoly/misc-utils/collection-tools';
import {
GuildData,
Member,
Role,
RoleSafety,
RoleTransaction,
RoleUpdate,
TransactionType,
} from '@roleypoly/types';
export const guildsRolesPut: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (!request.body) {
return invalid();
}
const updateRequest: RoleUpdate = await request.json();
if (updateRequest.transactions.length === 0) {
return invalid();
}
const guildID = context.params.guildId;
if (!guildID) {
return engineeringProblem('params not set up correctly');
}
const userID = context.session!.user.id;
const [member, guildData, guild] = await Promise.all([
getGuildMember(context.config, guildID, userID),
getGuildData(context.config, guildID),
getGuild(context.config, guildID),
]);
if (!guild || !member) {
return notFound();
}
const newRoles = calculateNewRoles({
currentRoles: member.roles,
guildRoles: guild.roles,
guildData,
updateRequest,
});
if (
isIdenticalArray(member.roles, newRoles) ||
isIdenticalArray(updateRequest.knownState, newRoles)
) {
return invalid();
}
const patchMemberRoles = await discordFetch<APIMember>(
`/guilds/${guildID}/members/${userID}`,
context.config.botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${context.config.uiPublicURI}`,
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return serverError(new Error('discord rejected the request'));
}
context.fetchContext.waitUntil(
updateGuildMember(context.config, guildID, patchMemberRoles)
);
const updatedMember: Member = {
roles: patchMemberRoles.roles,
};
return json(updatedMember);
};
export const calculateNewRoles = ({
currentRoles,
guildData,
guildRoles,
updateRequest,
}: {
currentRoles: string[];
guildRoles: Role[];
guildData: GuildData;
updateRequest: RoleUpdate;
}): string[] => {
const roleMap = keyBy(guildRoles, 'id');
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
// We could cause issues, so we'll re-add them later.
// const diffRoles = difference(updateRequest.knownState, currentRoles);
// Only these are safe
const allSafeRoles = guildData.categories.reduce<string[]>(
(categorizedRoles, category) =>
!category.hidden
? [
...categorizedRoles,
...category.roles.filter(
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
),
]
: categorizedRoles,
[]
);
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
allSafeRoles.includes(tx.id)
);
const changesByAction = safeTransactions.reduce<
Record<TransactionType, RoleTransaction[]>
>((group, value, _1, _2, key = value.action) => (group[key].push(value), group), {
[TransactionType.Add]: [],
[TransactionType.Remove]: [],
});
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map(
(tx: RoleTransaction) => tx.id
);
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
(tx: RoleTransaction) => tx.id
);
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
return final;
};

View file

@ -1,164 +0,0 @@
jest.mock('../../guilds/getters');
import { Features, GuildData, PresentableGuild } from '@roleypoly/types';
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
import { APIGuild, APIMember } from '../../utils/discord';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
const mockGetGuildMember = getGuildMember as jest.Mock;
const mockGetGuildData = getGuildData as jest.Mock;
beforeEach(() => {
mockGetGuildData.mockReset();
mockGetGuild.mockReset();
mockGetGuildMember.mockReset();
});
describe('GET /guilds/:id', () => {
it('returns a presentable guild', async () => {
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
],
};
const member: APIMember = {
roles: ['role-1'],
pending: false,
nick: '',
user: {
id: 'user-1',
},
};
const guildData: GuildData = {
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(member);
mockGetGuildData.mockReturnValue(guildData);
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
id: guild.id,
guild: session.guilds[0],
member: {
roles: member.roles,
},
roles: guild.roles,
data: guildData,
} as PresentableGuild);
});
it('returns a 404 when the guild is not in session', async () => {
const [config, context] = configContext();
const session = await makeSession(config);
const response = await makeRequest('GET', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(404);
});
it('returns 404 when the guild is not fetchable', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const response = await makeRequest('GET', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(404);
});
it('returns 404 when the member is no longer in the guild', async () => {
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
],
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(null);
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(404);
});
});

View file

@ -1,45 +0,0 @@
import {
getGuild,
getGuildData,
getGuildMember,
} from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { getQuery } from '@roleypoly/api/src/utils/request';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { PresentableGuild } from '@roleypoly/types';
export const guildsGuild: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const { __no_cache: noCache } = getQuery(request);
const guild = await getGuild(context.config, context.params!.guildId!, !!noCache);
if (!guild) {
return notFound();
}
const member = await getGuildMember(
context.config,
context.params!.guildId!,
context.session!.user.id,
!!noCache
);
if (!member) {
return notFound();
}
const data = await getGuildData(context.config, guild.id);
const presentableGuild: PresentableGuild = {
id: guild.id,
guild: context.session?.guilds.find((g) => g.id === guild.id)!,
roles: guild.roles,
member: {
roles: member.roles,
},
data,
};
return json(presentableGuild);
};

View file

@ -1,164 +0,0 @@
jest.mock('../../guilds/getters');
import {
Features,
GuildData,
GuildDataUpdate,
UserGuildPermissions,
} from '@roleypoly/types';
import { getGuildData } from '../../guilds/getters';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuildData = getGuildData as jest.Mock;
beforeAll(() => {
jest.resetAllMocks();
});
describe('PATCH /guilds/:id', () => {
it('updates guild data when user is an editor', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Manager,
},
],
});
mockGetGuildData.mockReturnValue({
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
} as GuildData);
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify({
message: 'hello test world!',
} as GuildDataUpdate),
});
expect(response.status).toBe(200);
const newGuildData = await config.kv.guildData.get('123');
expect(newGuildData).toMatchObject({
message: 'hello test world!',
});
});
it('ignores extraneous fields sent as updates', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Manager,
},
],
});
mockGetGuildData.mockReturnValue({
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
} as GuildData);
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify({
fifteen: 'foxes',
}),
});
expect(response.status).toBe(200);
const newGuildData = await config.kv.guildData.get('123');
expect(newGuildData).not.toMatchObject({
fifteen: 'foxes',
});
});
it('403s when user is not an editor', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.User,
},
],
});
mockGetGuildData.mockReturnValue({
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
} as GuildData);
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify({
message: 'hello test world!',
} as GuildDataUpdate),
});
expect(response.status).toBe(403);
});
it('400s when no body is present', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Manager,
},
],
});
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(400);
});
});

View file

@ -1,37 +0,0 @@
import { getGuildData } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { invalid, json, notFound } from '@roleypoly/api/src/utils/response';
import { GuildData, GuildDataUpdate } from '@roleypoly/types';
export const guildsGuildPatch: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const id = context.params.guildId!;
if (!request.body) {
return invalid();
}
const update: GuildDataUpdate = await request.json();
const oldGuildData = await getGuildData(context.config, id);
if (!oldGuildData) {
return notFound();
}
const newGuildData: GuildData = {
...oldGuildData,
// TODO: validation
message: update.message || oldGuildData.message,
categories: update.categories || oldGuildData.categories,
accessControl: update.accessControl || oldGuildData.accessControl,
// TODO: audit log webhooks
auditLogWebhook: oldGuildData.auditLogWebhook,
};
await context.config.kv.guildData.put(id, newGuildData);
return json(newGuildData);
};

View file

@ -1,52 +0,0 @@
jest.mock('../../guilds/getters');
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
import { getGuild } from '../../guilds/getters';
import { APIGuild } from '../../utils/discord';
import { makeRequest } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
beforeEach(() => {
mockGetGuild.mockReset();
});
describe('GET /guilds/:id/slug', () => {
it('returns a valid slug for a given discord server', async () => {
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
],
};
mockGetGuild.mockReturnValue(guild);
const response = await makeRequest('GET', `/guilds/${guild.id}/slug`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
id: guild.id,
icon: guild.icon,
name: guild.name,
permissionLevel: UserGuildPermissions.User,
} as GuildSlug);
});
it('returns a 404 if the guild cannot be fetched', async () => {
mockGetGuild.mockReturnValue(null);
const response = await makeRequest('GET', `/guilds/slug/123`);
expect(response.status).toBe(404);
});
});

View file

@ -1,31 +0,0 @@
import { getGuild } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
export const guildsSlug: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const id = context.params.guildId!;
const guildInSession = context.session?.guilds.find((guild) => guild.id === id);
if (guildInSession) {
return json<GuildSlug>(guildInSession);
}
const guild = await getGuild(context.config, id);
if (!guild) {
return notFound();
}
const slug: GuildSlug = {
id,
name: guild.name,
icon: guild.icon,
permissionLevel: UserGuildPermissions.User,
};
return json<GuildSlug>(slug);
};

View file

@ -1,60 +0,0 @@
jest.mock('../../../utils/discord');
import { discordFetch } from '../../../utils/discord';
import { configContext } from '../../../utils/testHelpers';
import {
extractInteractionResponse,
isDeferred,
isEphemeral,
makeInteractionsRequest,
mockUpdateCall,
} from '../testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with the username when member.nick is missing', async () => {
const [, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
false,
{
member: {
nick: undefined,
roles: [],
},
}
);
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(isDeferred(interaction)).toBe(true);
expect(isEphemeral(interaction)).toBe(true);
expect(mockDiscordFetch).toBeCalledWith(
...mockUpdateCall(expect, {
content: 'Hey there, test-user',
})
);
});
it('responds with the nickname when member.nick is set', async () => {
const [, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(isDeferred(interaction)).toBe(true);
expect(isEphemeral(interaction)).toBe(true);
expect(mockDiscordFetch).toBeCalledWith(
...mockUpdateCall(expect, {
content: 'Hey there, test-user-nick',
})
);
});

View file

@ -1,27 +0,0 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { Context } from '@roleypoly/api/src/utils/context';
import {
InteractionCallbackType,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld: InteractionHandler = (
interaction: InteractionRequest,
context: Context
): InteractionResponse => {
console.log({ interaction });
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${
interaction.member?.nick ||
interaction.member?.user?.username ||
interaction.user?.username
}`,
},
};
};
helloWorld.ephemeral = true;
helloWorld.deferred = true;

View file

@ -1,18 +0,0 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { rolePickerCommon } from '@roleypoly/api/src/routes/interactions/role-picker-common';
import { Context } from '@roleypoly/api/src/utils/context';
import {
InteractionRequest,
InteractionResponse,
TransactionType,
} from '@roleypoly/types';
export const pickRole: InteractionHandler = async (
interaction: InteractionRequest,
context: Context
): Promise<InteractionResponse> => {
return rolePickerCommon(interaction, context, TransactionType.Add);
};
pickRole.ephemeral = true;
pickRole.deferred = true;

View file

@ -1,118 +0,0 @@
import {
getGuild,
getGuildData,
getGuildMember,
getPickableRoles,
} from '@roleypoly/api/src/guilds/getters';
import {
embedBuilder,
getName,
InteractionHandler,
} from '@roleypoly/api/src/routes/interactions/helpers';
import {
embedPalette,
embedResponse,
} from '@roleypoly/api/src/routes/interactions/responses';
import { Context } from '@roleypoly/api/src/utils/context';
import {
CategoryType,
Embed,
InteractionCallbackType,
InteractionRequest,
InteractionResponse,
Role,
} from '@roleypoly/types';
export const pickableRoles: InteractionHandler = async (
interaction: InteractionRequest,
context: Context
): Promise<InteractionResponse> => {
if (!interaction.guild_id) {
return embedResponse(
':x: Error',
`Hey ${getName(
interaction
)}. You need to use this command in a server, not in a DM.`,
embedPalette.error
);
}
const [guildData, guild, member] = await Promise.all([
getGuildData(context.config, interaction.guild_id),
getGuild(context.config, interaction.guild_id),
getGuildMember(context.config, interaction.guild_id, interaction.member?.user?.id!),
]);
if (!guildData || !guild) {
return embedResponse(
':x: Error',
`Hey ${getName(
interaction
)}. Something's wrong with the server you're in. Try picking your roles at ${
context.config.uiPublicURI
}/s/${interaction.guild_id} instead.`,
embedPalette.error
);
}
const roles = getPickableRoles(guildData, guild);
if (roles.length === 0) {
return embedResponse(
':fire: Error',
`Hey ${getName(
interaction
)}. This server might not be set up to use Roleypoly yet, as there are no roles to pick from.`,
embedPalette.error
);
}
const makeBoldIfMemberHasRole = (role: Role, base: string): string => {
if (member?.roles.includes(role.id)) {
return `__${base}__`;
}
return base;
};
const embed: Embed = {
color: embedPalette.neutral,
fields: roles.map(({ category, roles }) => {
return {
name: `${category.name}${
category.type === CategoryType.Single ? ' *(pick one)*' : ''
}`,
value: roles
.map((role) => makeBoldIfMemberHasRole(role, `<@&${role.id}>`))
.join(', '),
} as Embed['fields'][0];
}),
title: 'You can pick any of these roles with /pick-role',
footer: {
text: `Roles with an __underline__ are already picked by you.`,
},
};
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: embedBuilder(embed),
components: [
{
type: 1,
components: [
// Link to Roleypoly
{
type: 2,
label: 'Pick roles on your browser',
url: `${context.config.uiPublicURI}/s/${interaction.guild_id}`,
style: 5,
},
],
},
],
},
};
};
pickableRoles.ephemeral = true;
pickableRoles.deferred = true;

View file

@ -1,18 +0,0 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { rolePickerCommon } from '@roleypoly/api/src/routes/interactions/role-picker-common';
import { Context } from '@roleypoly/api/src/utils/context';
import {
InteractionRequest,
InteractionResponse,
TransactionType,
} from '@roleypoly/types';
export const removeRole: InteractionHandler = async (
interaction: InteractionRequest,
context: Context
): Promise<InteractionResponse> => {
return rolePickerCommon(interaction, context, TransactionType.Remove);
};
removeRole.ephemeral = true;
removeRole.deferred = true;

View file

@ -1,60 +0,0 @@
import {
getName,
InteractionHandler,
} from '@roleypoly/api/src/routes/interactions/helpers';
import { embedResponse } from '@roleypoly/api/src/routes/interactions/responses';
import { Context } from '@roleypoly/api/src/utils/context';
import {
Embed,
InteractionCallbackType,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const roleypoly: InteractionHandler = (
interaction: InteractionRequest,
context: Context
): InteractionResponse => {
if (!interaction.guild_id) {
return embedResponse(
':x: Error',
`Hey ${getName(
interaction
)}. You need to use this command in a server, not in a DM.`
);
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
color: 0x453e3d,
title: `:beginner: Hey there, ${getName(interaction)}!`,
description: `Try these slash commands, or pick roles from your browser!`,
fields: [
{ name: 'See all the roles', value: '/pickable-roles' },
{ name: 'Pick a role', value: '/pick-role' },
{ name: 'Remove a role', value: '/remove-role' },
],
} as Embed,
],
components: [
{
type: 1,
components: [
// Link to Roleypoly
{
type: 2,
label: `Pick roles on ${new URL(context.config.uiPublicURI).hostname}`,
url: `${context.config.uiPublicURI}/s/${interaction.guild_id}`,
style: 5,
},
],
},
],
},
};
};
roleypoly.ephemeral = true;

View file

@ -1,179 +0,0 @@
import { InteractionRequest, InteractionType } from '@roleypoly/types';
import nacl from 'tweetnacl';
import { configContext } from '../../utils/testHelpers';
import { embedBuilder, verifyRequest } from './helpers';
//
// Q: Why tweetnacl when WebCrypto is available?
// A: Discord uses tweetnacl on their end, thus is also
// used in far more examples of Discord Interactions than WebCrypto.
// We don't actually use it in Workers, as SubtleCrypto using NODE-ED25519
// is better in every way, and still gives us the same effect.
//
describe('verifyRequest', () => {
it('validates a successful Discord interactions request', async () => {
const [config, context] = configContext();
const timestamp = String(Date.now());
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey, secretKey } = nacl.sign.keyPair();
const signature = nacl.sign.detached(
Buffer.from(timestamp + JSON.stringify(body)),
secretKey
);
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'x-signature-timestamp': timestamp,
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
},
});
expect(await verifyRequest(context.config, request, body)).toBe(true);
});
it('fails to validate a headerless Discord interactions request', async () => {
const [config, context] = configContext();
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey, secretKey } = nacl.sign.keyPair();
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {},
});
expect(await verifyRequest(context.config, request, body)).toBe(false);
});
it('fails to validate a bad signature from Discord', async () => {
const [config, context] = configContext();
const timestamp = String(Date.now());
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey } = nacl.sign.keyPair();
const { secretKey: otherKey } = nacl.sign.keyPair();
const signature = nacl.sign.detached(
Buffer.from(timestamp + JSON.stringify(body)),
otherKey
);
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'x-signature-timestamp': timestamp,
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
},
});
expect(await verifyRequest(context.config, request, body)).toBe(false);
});
it('fails to validate when signature differs from data', async () => {
const [config, context] = configContext();
const timestamp = String(Date.now());
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey, secretKey } = nacl.sign.keyPair();
const signature = nacl.sign.detached(
Buffer.from(timestamp + JSON.stringify({ ...body, id: '456' })),
secretKey
);
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'x-signature-timestamp': timestamp,
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
},
});
expect(await verifyRequest(context.config, request, body)).toBe(false);
});
});
describe('embedBuilder', () => {
it('builds embeds that discord approves of', () => {
const embeds = embedBuilder({
title: 'Test',
fields: [
{
name: 'Field 1',
value: 'role-1, role-2, role-3, role-4, role-5, '
.repeat(1024 / 30 - 15)
.replace(/, $/, ''),
},
{
name: 'Field 2',
value: 'role-1, role-2, role-3, role-4, role-5, '
.repeat(1024 / 30 + 4)
.replace(/, $/, ''),
},
],
color: 0xff0000,
});
expect(embeds).toMatchInlineSnapshot(`
Array [
Object {
"color": 16711680,
"fields": Array [
Object {
"name": "Field 1",
"value": "role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5",
},
Object {
"name": "Field 2",
"value": "role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3",
},
Object {
"name": "Field 2 (continued)",
"value": "role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5",
},
],
"title": "Test",
},
]
`);
expect(embeds.length).toBe(1);
expect(embeds[0].fields.length).toBe(3);
});
});

View file

@ -1,203 +0,0 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { Embed, InteractionRequest, InteractionResponse } from '@roleypoly/types';
export const verifyRequest = async (
config: Config,
request: Request,
interaction: InteractionRequest
): Promise<boolean> => {
try {
const timestamp = request.headers.get('x-signature-timestamp');
const signature = request.headers.get('x-signature-ed25519');
if (!timestamp || !signature) {
return false;
}
const key = await crypto.subtle.importKey(
'raw',
bufferizeHex(config.publicKey),
{ name: 'NODE-ED25519', namedCurve: 'NODE-ED25519', public: true } as any,
false,
['verify']
);
const verified = await crypto.subtle.verify(
'NODE-ED25519',
key,
bufferizeHex(signature),
bufferizeString(timestamp + JSON.stringify(interaction))
);
return verified;
} catch (e) {
return false;
}
};
// Cloudflare Workers + SubtleCrypto has no idea what a Buffer.from() is.
// What the fuck?
const bufferizeHex = (input: string) => {
const buffer = new Uint8Array(input.length / 2);
for (let i = 0; i < input.length; i += 2) {
buffer[i / 2] = parseInt(input.substring(i, i + 2), 16);
}
return buffer;
};
const bufferizeString = (input: string) => {
const encoder = new TextEncoder();
return encoder.encode(input);
};
export type InteractionHandler = ((
interaction: InteractionRequest,
context: Context
) => Promise<InteractionResponse> | InteractionResponse) & {
ephemeral?: boolean;
deferred?: boolean;
};
export const runAsync = async (
handler: InteractionHandler,
interaction: InteractionRequest,
context: Context
): Promise<void> => {
const url = `/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`;
try {
const response = await handler(interaction, context);
if (!response) {
throw new Error('Interaction handler returned no response');
}
console.log({ response });
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...response.data,
}),
});
} catch (e) {
console.error('/interations runAsync failed', {
e,
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
});
try {
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: "I'm sorry, I'm having trouble processing this request.",
} as InteractionResponse['data']),
});
} catch (e) {}
}
};
export const getName = (interaction: InteractionRequest): string => {
return (
interaction.member?.nick ||
interaction.member?.user?.username ||
interaction.user?.username ||
'friend'
);
};
/**
* Take a single big embed and fit it into Discord limits
* per embed, 25 fields, and 1024 characters per field.
* so we'll make new embeds/fields as the content gets too long.
*/
export const embedBuilder = (embed: Embed): Embed[] => {
const embeds: Embed[] = [];
const titleCorrection = (title: string, withContinued?: boolean) => {
const suffix = withContinued ? '... (continued)' : '...';
const offsetTitle = title.length + suffix.length;
return title.length > 256 - offsetTitle
? title.slice(0, 256 - offsetTitle) + suffix
: withContinued
? `${title} (continued)`
: title;
};
let currentEmbed: Embed = {
color: embed.color,
title: embed.title,
fields: [],
};
let knownFieldTitles: string[] = [];
const commitField = (field: Embed['fields'][0]) => {
if (currentEmbed.fields.length === 25) {
embeds.push(currentEmbed);
currentEmbed = {
color: embed.color,
title: `${embed.title} (continued)`,
fields: [],
};
}
console.warn({ field });
const addContinued = knownFieldTitles.includes(field.name);
if (!addContinued) {
knownFieldTitles.push(field.name);
}
field.name = titleCorrection(`${field.name}`, addContinued);
console.warn({ field, knownFieldTitles });
currentEmbed.fields.push(field);
};
for (let field of embed.fields) {
if (field.value.length <= 1024) {
commitField(field);
continue;
}
const split = field.value.split(', '); // we know we'll be using , as a delimiter
let fieldValue: Embed['fields'][0]['value'] = '';
for (let part of split) {
if (fieldValue.length + part.length > 1024) {
commitField({
name: field.name,
value: fieldValue.replace(/, $/, ''),
});
fieldValue = '';
} else {
fieldValue += part + ', ';
}
}
if (fieldValue.length > 0) {
commitField({
name: field.name,
value: fieldValue.replace(/, $/, ''),
});
}
}
if (currentEmbed.fields.length > 0) {
embeds.push(currentEmbed);
}
return embeds;
};

View file

@ -1,45 +0,0 @@
jest.mock('../../utils/discord');
import { InteractionCallbackType, InteractionFlags } from '@roleypoly/types';
import { AuthType, discordFetch } from '../../utils/discord';
import { configContext } from '../../utils/testHelpers';
import { extractInteractionResponse, makeInteractionsRequest } from './testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with a simple hello-world!', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(interaction.type).toEqual(
InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
);
expect(interaction.data).toEqual({
flags: InteractionFlags.EPHEMERAL,
});
expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, {
body: JSON.stringify({
content: 'Hey there, test-user-nick',
}),
headers: expect.any(Object),
method: 'PATCH',
});
});
it('does not allow requests that are invalid', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
true
);
expect(response.status).toBe(401);
});

View file

@ -1,92 +0,0 @@
import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world';
import { pickRole } from '@roleypoly/api/src/routes/interactions/commands/pick-role';
import { pickableRoles } from '@roleypoly/api/src/routes/interactions/commands/pickable-roles';
import { removeRole } from '@roleypoly/api/src/routes/interactions/commands/remove-role';
import { roleypoly } from '@roleypoly/api/src/routes/interactions/commands/roleypoly';
import {
InteractionHandler,
runAsync,
verifyRequest,
} from '@roleypoly/api/src/routes/interactions/helpers';
import { notImplemented } from '@roleypoly/api/src/routes/interactions/responses';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { invalid, json } from '@roleypoly/api/src/utils/response';
import {
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
const commands: Record<InteractionData['name'], InteractionHandler> = {
'hello-world': helloWorld,
roleypoly: roleypoly,
'pickable-roles': pickableRoles,
'pick-role': pickRole,
'remove-role': removeRole,
};
export const handleInteraction: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const interaction: InteractionRequest = await request.json();
if (!interaction) {
return invalid();
}
if (!(await verifyRequest(context.config, request, interaction))) {
console.warn('interactions: invalid signature');
return new Response('invalid request signature', { status: 401 });
}
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
if (interaction.type === InteractionType.PING) {
console.info('interactions: ping');
return json({ type: InteractionCallbackType.PONG });
}
console.warn('interactions: not application command');
return json({ err: 'not implemented' }, { status: 400 });
}
if (!interaction.data) {
return json({ err: 'data missing' }, { status: 400 });
}
const handler = commands[interaction.data.name] || notImplemented;
try {
if (handler.deferred) {
context.fetchContext.waitUntil(runAsync(handler, interaction, context));
return json({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
},
} as InteractionResponse);
}
const response = await handler(interaction, context);
return json({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
});
} catch (e) {
console.error('/interactions error:', {
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
e,
});
return invalid();
}
};

View file

@ -1,61 +0,0 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import {
InteractionCallbackType,
InteractionFlags,
InteractionResponse,
} from '@roleypoly/types';
export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command has to be used in a server.',
flags: InteractionFlags.EPHEMERAL,
},
});
export const invalid: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: You filled that command out wrong...',
flags: InteractionFlags.EPHEMERAL,
},
});
export const somethingWentWrong: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
flags: InteractionFlags.EPHEMERAL,
},
});
export const notImplemented: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command is not implemented yet.',
flags: InteractionFlags.EPHEMERAL,
},
});
export const embedResponse = (
title: string,
description: string,
color?: number
): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
color: color || 0x00ff00,
title,
description,
},
],
},
});
export const embedPalette = {
success: 0x1d8227,
error: 0xf14343,
neutral: 0x453e3d,
};

View file

@ -1,155 +0,0 @@
import { getGuild, getGuildData } from '@roleypoly/api/src/guilds/getters';
import { calculateNewRoles } from '@roleypoly/api/src/routes/guilds/guild-roles-put';
import { getName } from '@roleypoly/api/src/routes/interactions/helpers';
import {
embedPalette,
embedResponse,
} from '@roleypoly/api/src/routes/interactions/responses';
import { Context } from '@roleypoly/api/src/utils/context';
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { isIdenticalArray } from '@roleypoly/misc-utils/collection-tools';
import {
CategoryType,
InteractionRequest,
InteractionResponse,
RoleTransaction,
TransactionType,
} from '@roleypoly/types';
export const rolePickerCommon = async (
interaction: InteractionRequest,
context: Context,
action: TransactionType
): Promise<InteractionResponse> => {
if (!interaction.guild_id) {
return embedResponse(
':x: Error',
`Hey ${getName(
interaction
)}. You need to use this command in a server, not in a DM.`
);
}
const currentRoles = interaction.member?.roles || [];
const [guildData, guild] = await Promise.all([
getGuildData(context.config, interaction.guild_id),
getGuild(context.config, interaction.guild_id),
]);
if (!guildData || !guild) {
return embedResponse(
':x: Error',
`Hey ${getName(
interaction
)}. Something's wrong with the server you're in. Try picking your roles at ${
context.config.uiPublicURI
}/s/${interaction.guild_id} instead.`,
embedPalette.error
);
}
const roleToPick = interaction.data?.options?.[0]?.value;
if (!roleToPick) {
return embedResponse(
':fire: Discord sent me the wrong data',
`Hey ${getName(interaction)}. Please try again later.`,
embedPalette.error
);
}
const hasRole = interaction.member?.roles.includes(roleToPick);
if (action === TransactionType.Add && hasRole) {
return embedResponse(
`:white_check_mark: You already have that role.`,
`Hey ${getName(interaction)}. You already have <@&${roleToPick}>!`,
embedPalette.neutral
);
}
if (action === TransactionType.Remove && !hasRole) {
return embedResponse(
`:white_check_mark: You don't have that role.`,
`Hey ${getName(interaction)}. You already don't have <@&${roleToPick}>!`,
embedPalette.neutral
);
}
const extraTransactions: RoleTransaction[] = [];
let isSingle = false;
if (action === TransactionType.Add) {
// For single-type categories, let's also generate the remove rules for the other roles in the category
const category = guildData.categories.find((category) =>
category.roles.includes(roleToPick)
);
if (category?.type === CategoryType.Single) {
const otherRoles = category.roles.filter((role) => role !== roleToPick);
extraTransactions.push(
...otherRoles.map((role) => ({ action: TransactionType.Remove, id: role }))
);
isSingle = true;
}
}
const newRoles = calculateNewRoles({
currentRoles,
guildRoles: guild.roles,
guildData,
updateRequest: {
knownState: currentRoles,
transactions: [{ action, id: roleToPick }, ...extraTransactions],
},
});
if (isIdenticalArray(currentRoles, newRoles)) {
return embedResponse(
':x: You cannot pick this role.',
`Hey ${getName(
interaction
)}. <@&${roleToPick}> isn't pickable. Check /pickable-roles to see which roles you can use.`,
embedPalette.error
);
}
const patchMemberRoles = await discordFetch<APIMember>(
`/guilds/${interaction.guild_id}/members/${interaction.member?.user?.id}`,
context.config.botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via /${
action === TransactionType.Add ? 'pick' : 'remove'
}-role`,
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return embedResponse(
':x: Discord stopped me from updating your roles.',
`Hey ${getName(
interaction
)}. Discord didn't let me give you <@&${roleToPick}>. Could you try again later?`,
embedPalette.error
);
}
return action === TransactionType.Add
? embedResponse(
':white_check_mark: You got it!',
`Hey ${getName(interaction)}, I gave you <@&${roleToPick}>!${
isSingle ? `\nThe other roles in this category have been removed.` : ''
}`,
embedPalette.success
)
: embedResponse(
":white_check_mark: You (don't) got it!",
`Hey ${getName(interaction)}, I took away <@&${roleToPick}>!`,
embedPalette.success
);
};

View file

@ -1,130 +0,0 @@
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType } from '@roleypoly/api/src/utils/discord';
import { getID } from '@roleypoly/api/src/utils/id';
import {
InteractionCallbackData,
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
import nacl from 'tweetnacl';
const { publicKey, secretKey } = nacl.sign.keyPair();
const hexPublicKey = Buffer.from(publicKey).toString('hex');
export const getSignatureHeaders = (
context: Context,
interaction: InteractionRequest
): {
'x-signature-ed25519': string;
'x-signature-timestamp': string;
} => {
const timestamp = Date.now().toString();
const body = JSON.stringify(interaction);
const signature = nacl.sign.detached(Buffer.from(timestamp + body), secretKey);
return {
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
'x-signature-timestamp': timestamp,
};
};
export const makeInteractionsRequest = async (
context: Context,
interactionData: Partial<InteractionData>,
forceInvalid?: boolean,
topLevelMixin?: Partial<InteractionRequest>
): Promise<Response> => {
context.config.publicKey = hexPublicKey;
const interaction: InteractionRequest = {
data: {
id: getID(),
name: 'hello-world',
...interactionData,
} as InteractionData,
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: context.config.botClientID,
token: getID(),
version: 1,
user: {
id: '123',
username: 'test-user',
discriminator: '1234',
bot: false,
avatar: '',
},
member: {
nick: 'test-user-nick',
roles: [],
},
...topLevelMixin,
};
const request = new Request('http://localhost:3000/interactions', {
method: 'POST',
headers: {
'content-type': 'application/json',
...getSignatureHeaders(context, {
...interaction,
...(forceInvalid ? { id: 'invalid-id' } : {}),
}),
},
body: JSON.stringify(interaction),
});
return handleInteraction(request, context);
};
export const extractInteractionResponse = async (
response: Response
): Promise<InteractionResponse> => {
const body = await response.json();
return body as InteractionResponse;
};
export const isDeferred = (response: InteractionResponse): boolean => {
return response.type === InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE;
};
export const isEphemeral = (response: InteractionResponse): boolean => {
return (
(response.data?.flags || 0 & InteractionFlags.EPHEMERAL) ===
InteractionFlags.EPHEMERAL
);
};
export const interactionData = (
response: InteractionResponse
): Omit<InteractionCallbackData, 'flags'> | undefined => {
const { data } = response;
if (!data) return undefined;
delete data.flags;
return response.data;
};
export const mockUpdateCall = (
expect: any,
data: Omit<InteractionCallbackData, 'flags'>
) => {
return [
expect.any(String),
'',
AuthType.None,
{
body: JSON.stringify({
...data,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'PATCH',
},
];
};

View file

@ -1,53 +0,0 @@
jest.mock('../utils/discord');
import { AuthTokenResponse } from '@roleypoly/types';
import { parseEnvironment } from '../utils/config';
import { getTokenGuilds, getTokenUser } from '../utils/discord';
import { getBindings } from '../utils/testHelpers';
import { createSession } from './create';
const mockGetTokenGuilds = getTokenGuilds as jest.Mock;
const mockGetTokenUser = getTokenUser as jest.Mock;
it('creates a session from tokens', async () => {
const config = parseEnvironment(getBindings());
const tokens: AuthTokenResponse = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
};
mockGetTokenUser.mockReturnValueOnce({
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
});
mockGetTokenGuilds.mockReturnValueOnce([]);
const session = await createSession(config, tokens);
expect(session).toEqual({
sessionID: expect.any(String),
user: {
id: 'test-user-id',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
username: 'test-username',
},
guilds: [],
tokens,
});
expect(mockGetTokenUser).toBeCalledWith(tokens.access_token);
expect(mockGetTokenGuilds).toBeCalledWith(tokens.access_token);
const savedSession = await config.kv.sessions.get(session?.sessionID || '');
expect(savedSession).toEqual(session);
});

View file

@ -1,31 +0,0 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { getTokenGuilds, getTokenUser } from '@roleypoly/api/src/utils/discord';
import { getID } from '@roleypoly/api/src/utils/id';
import { AuthTokenResponse, SessionData } from '@roleypoly/types';
export const createSession = async (
config: Config,
tokens: AuthTokenResponse
): Promise<SessionData | null> => {
const [user, guilds] = await Promise.all([
getTokenUser(tokens.access_token),
getTokenGuilds(tokens.access_token),
]);
if (!user) {
return null;
}
const sessionID = getID();
const session: SessionData = {
sessionID,
user,
guilds,
tokens,
};
await config.kv.sessions.put(sessionID, session, config.retention.session);
return session;
};

View file

@ -1,172 +0,0 @@
import { Router } from 'itty-router';
import { Context } from '../utils/context';
import { json } from '../utils/response';
import { configContext, makeSession } from '../utils/testHelpers';
import { requireSession, withAuthMode, withSession } from './middleware';
it('detects anonymous auth mode via middleware', async () => {
const [, context] = configContext();
const router = Router();
const testFn = jest.fn();
router.all('*', withAuthMode).get('/', (request, context) => {
expect(context.authMode.type).toBe('anonymous');
testFn();
return json({});
});
await router.handle(new Request('http://test.local/'), context);
expect(testFn).toHaveBeenCalled();
});
it('detects bearer auth mode via middleware', async () => {
const [, context] = configContext();
const testFn = jest.fn();
const token = 'abc123';
const router = Router();
router.all('*', withAuthMode).get('/', (request, context) => {
expect(context.authMode.type).toBe('bearer');
expect(context.authMode.sessionId).toBe(token);
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer ${token}`,
},
}),
context
);
expect(testFn).toHaveBeenCalled();
});
it('detects bot auth mode via middleware', async () => {
const testFn = jest.fn();
const [, context] = configContext();
const token = 'abc123';
const router = Router();
router.all('*', withAuthMode).get('/', (request, context) => {
expect(context.authMode.type).toBe('bot');
expect(context.authMode.identity).toBe(token);
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bot ${token}`,
},
}),
context
);
expect(testFn).toHaveBeenCalled();
});
it('sets Context.session via withSession middleware', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => {
expect(context.session).toBeDefined();
expect(context.session!.sessionID).toBe(session.sessionID);
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
context
);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('does not set Context.session when session is invalid', async () => {
const testFn = jest.fn();
const [, context] = configContext();
const router = Router();
router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => {
expect(context.session).not.toBeDefined();
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer abc123`,
},
}),
context
);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('errors with 401 when requireSession is coupled with invalid session', async () => {
const [, context] = configContext();
const router = Router();
const testFn = jest.fn();
router
.all('*', withAuthMode, withSession, requireSession)
.get('/', (request, context: Context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer abc123`,
},
}),
context
);
expect(testFn).not.toHaveBeenCalled();
expect(response.status).toBe(401);
});
it('passes through when requireSession is coupled with a valid session', async () => {
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
const testFn = jest.fn();
router
.all('*', withAuthMode, withSession, requireSession)
.get('/', (request, context: Context) => {
expect(context.session).toBeDefined();
testFn();
return json({});
});
const response = await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
context
);
expect(response.status).toBe(200);
expect(testFn).toHaveBeenCalled();
});

View file

@ -1,67 +0,0 @@
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
import { unauthorized } from '@roleypoly/api/src/utils/response';
import { SessionData } from '@roleypoly/types';
export const withSession: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (context.authMode.type !== 'bearer') {
return;
}
const session = await context.config.kv.sessions.get<SessionData>(
context.authMode.sessionId
);
if (!session) {
return;
}
context.session = session;
};
export const requireSession: RoleypolyMiddleware = (
request: Request,
context: Context
) => {
if (context.authMode.type !== 'bearer' || !context.session) {
return unauthorized();
}
};
export const withAuthMode: RoleypolyMiddleware = (request: Request, context: Context) => {
const auth = extractAuthentication(request);
if (auth.authType === 'Bearer') {
context.authMode = {
type: 'bearer',
sessionId: auth.token,
};
return;
}
if (auth.authType === 'Bot') {
context.authMode = {
type: 'bot',
identity: auth.token,
};
return;
}
context.authMode = {
type: 'anonymous',
};
};
export const extractAuthentication = (
request: Request
): { authType: string; token: string } => {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return { authType: 'None', token: '' };
}
const [authType, token] = authHeader.split(' ');
return { authType, token };
};

View file

@ -1,25 +0,0 @@
import { parseEnvironment } from '../utils/config';
import { getBindings } from '../utils/testHelpers';
import { getStateSession, setupStateSession } from './state';
it('creates and fetches a state session', async () => {
const config = parseEnvironment(getBindings());
const stateID = await setupStateSession(config, {
test: 'test-data',
});
const stateSession = await getStateSession(config, stateID);
expect(stateSession).toEqual({
test: 'test-data',
});
});
it('returns undefined when state is invalid', async () => {
const config = parseEnvironment(getBindings());
const stateSession = await getStateSession(config, 'invalid-state-id');
expect(stateSession).toBeUndefined();
});

View file

@ -1,19 +0,0 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { getID } from '@roleypoly/api/src/utils/id';
export const setupStateSession = async <T>(config: Config, data: T): Promise<string> => {
const stateID = getID();
await config.kv.sessions.put(`state_${stateID}`, { data }, config.retention.session);
return stateID;
};
export const getStateSession = async <T>(
config: Config,
stateID: string
): Promise<T | undefined> => {
const stateSession = await config.kv.sessions.get<{ data: T }>(`state_${stateID}`);
return stateSession?.data;
};

View file

@ -1,78 +0,0 @@
import { WrappedKVNamespace } from './kv';
export type Environment = {
BOT_CLIENT_ID: string;
BOT_CLIENT_SECRET: string;
BOT_TOKEN: string;
UI_PUBLIC_URI: string;
API_PUBLIC_URI: string;
ROOT_USERS: string;
ALLOWED_CALLBACK_HOSTS: string;
BOT_IMPORT_TOKEN: string;
INTERACTIONS_SHARED_KEY: string;
RP_SERVER_ID: string;
RP_HELPER_ROLE_IDS: string;
DISCORD_PUBLIC_KEY: string;
KV_SESSIONS: KVNamespace;
KV_GUILDS: KVNamespace;
KV_GUILD_DATA: KVNamespace;
};
export type Config = {
botClientID: string;
botClientSecret: string;
botToken: string;
publicKey: string;
uiPublicURI: string;
apiPublicURI: string;
rootUsers: string[];
allowedCallbackHosts: string[];
importSharedKey: string;
interactionsSharedKey: string;
roleypolyServerID: string;
helperRoleIDs: string[];
kv: {
sessions: WrappedKVNamespace;
guilds: WrappedKVNamespace;
guildData: WrappedKVNamespace;
};
retention: {
session: number;
sessionState: number;
guild: number;
member: number;
};
_raw: Environment;
};
const toList = (x: string): string[] => String(x).split(',');
const safeURI = (x: string) => String(x).replace(/\/$/, '');
export const parseEnvironment = (env: Environment): Config => {
return {
_raw: env,
botClientID: env.BOT_CLIENT_ID,
botClientSecret: env.BOT_CLIENT_SECRET,
botToken: env.BOT_TOKEN,
publicKey: env.DISCORD_PUBLIC_KEY,
uiPublicURI: safeURI(env.UI_PUBLIC_URI),
apiPublicURI: safeURI(env.API_PUBLIC_URI),
rootUsers: toList(env.ROOT_USERS),
allowedCallbackHosts: toList(env.ALLOWED_CALLBACK_HOSTS),
importSharedKey: env.BOT_IMPORT_TOKEN,
interactionsSharedKey: env.INTERACTIONS_SHARED_KEY,
roleypolyServerID: env.RP_SERVER_ID,
helperRoleIDs: toList(env.RP_HELPER_ROLE_IDS),
kv: {
sessions: new WrappedKVNamespace(env.KV_SESSIONS),
guilds: new WrappedKVNamespace(env.KV_GUILDS),
guildData: new WrappedKVNamespace(env.KV_GUILD_DATA),
},
retention: {
session: 60 * 60 * 6, // 6 hours
sessionState: 60 * 5, // 5 minutes
guild: 60 * 15, // 15 minutes
member: 60 * 5, // 5 minutes
},
};
};

View file

@ -1,40 +0,0 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { SessionData } from '@roleypoly/types';
export type AuthMode =
| {
type: 'anonymous';
}
| {
type: 'bearer';
sessionId: string;
}
| {
type: 'bot';
identity: string;
};
export type Context = {
config: Config;
fetchContext: {
waitUntil: FetchEvent['waitUntil'];
};
authMode: AuthMode;
params: {
guildId?: string;
memberId?: string;
};
// Must include withSession middleware for population
session?: SessionData;
};
export type RoleypolyHandler = (
request: Request,
context: Context
) => Promise<Response> | Response;
export type RoleypolyMiddleware = (
request: Request,
context: Context
) => Promise<Response | void> | Response | void;

View file

@ -1,34 +0,0 @@
import { getHighestRole } from './discord';
describe('getHighestRole', () => {
it('returns the highest role', () => {
const roles = [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
{
id: 'role-2',
name: 'Role 2',
color: 0,
position: 2,
permissions: '',
managed: false,
},
{
id: 'role-3',
name: 'Role 3',
color: 0,
position: 19,
permissions: '',
managed: false,
},
];
expect(getHighestRole(roles)).toEqual(roles[2]);
});
});

Some files were not shown because too many files have changed in this diff Show more