Showing preview only (371K chars total). Download the full file or copy to clipboard to get everything.
Repository: OultimoCoder/cloudflare-planetscale-hono-boilerplate
Branch: main
Commit: 3ceabdc9c29a
Files: 99
Total size: 344.8 KB
Directory structure:
gitextract_e9sl75y4/
├── .dev.vars.example
├── .env.example
├── .env.test.example
├── .eslintignore
├── .gitignore
├── .husky/
│ ├── pre-commit
│ └── pre-push
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── TODO.md
├── bin/
│ └── createApp.js
├── bindings.d.ts
├── build.js
├── eslint.config.js
├── jest.config.js
├── migrations/
│ └── 01_initial.ts
├── package.json
├── scripts/
│ └── migrate.ts
├── src/
│ ├── config/
│ │ ├── authProviders.ts
│ │ ├── config.ts
│ │ ├── database.ts
│ │ ├── roles.ts
│ │ └── tokens.ts
│ ├── controllers/
│ │ ├── auth/
│ │ │ ├── auth.controller.ts
│ │ │ └── oauth/
│ │ │ ├── apple.controller.ts
│ │ │ ├── discord.controller.ts
│ │ │ ├── facebook.controller.ts
│ │ │ ├── github.controller.ts
│ │ │ ├── google.controller.ts
│ │ │ ├── oauth.controller.ts
│ │ │ └── spotify.controller.ts
│ │ └── user.controller.ts
│ ├── durable-objects/
│ │ └── rate-limiter.do.ts
│ ├── factories/
│ │ └── oauth.factory.ts
│ ├── index.ts
│ ├── middlewares/
│ │ ├── auth.ts
│ │ ├── error.ts
│ │ └── rate-limiter.ts
│ ├── models/
│ │ ├── base.model.ts
│ │ ├── oauth/
│ │ │ ├── apple-user.model.ts
│ │ │ ├── discord-user.model.ts
│ │ │ ├── facebook-user.model.ts
│ │ │ ├── github-user.model.ts
│ │ │ ├── google-user.model.ts
│ │ │ ├── oauth-base.model.ts
│ │ │ └── spotify-user.model.ts
│ │ ├── one-time-oauth-code.ts
│ │ ├── token.model.ts
│ │ └── user.model.ts
│ ├── routes/
│ │ ├── auth.route.ts
│ │ ├── index.ts
│ │ └── user.route.ts
│ ├── services/
│ │ ├── auth.service.ts
│ │ ├── email.service.ts
│ │ ├── oauth/
│ │ │ ├── apple.service.ts
│ │ │ ├── facebook.service.ts
│ │ │ ├── github.service.ts
│ │ │ └── spotify.service.ts
│ │ ├── token.service.ts
│ │ └── user.service.ts
│ ├── tables/
│ │ ├── oauth.table.ts
│ │ ├── one-time-oauth-code.table.ts
│ │ └── user.table.ts
│ ├── types/
│ │ └── oauth.types.ts
│ ├── utils/
│ │ ├── api-error.ts
│ │ ├── utils.ts
│ │ └── zod.ts
│ └── validations/
│ ├── auth.validation.ts
│ ├── custom.refine.validation.ts
│ ├── custom.transform.validation.ts
│ ├── custom.type.validation.ts
│ └── user.validation.ts
├── tests/
│ ├── cloudflare-test.d.ts
│ ├── fixtures/
│ │ ├── authorisations.fixture.ts
│ │ ├── token.fixture.ts
│ │ └── user.fixture.ts
│ ├── integration/
│ │ ├── auth/
│ │ │ ├── auth.test.ts
│ │ │ └── oauth/
│ │ │ ├── apple.test.ts
│ │ │ ├── discord.test.ts
│ │ │ ├── facebook.test.ts
│ │ │ ├── github.test.ts
│ │ │ ├── google.test.ts
│ │ │ └── spotify.test.ts
│ │ ├── index.test.ts
│ │ ├── rate-limiter.test.ts
│ │ └── user.test.ts
│ ├── mocks/
│ │ └── awsClientStub/
│ │ ├── aws-client-stub.ts
│ │ ├── expect-mock.ts
│ │ ├── index.ts
│ │ └── mock-client.ts
│ ├── tsconfig.json
│ ├── utils/
│ │ ├── clear-db-tables.ts
│ │ └── test-request.ts
│ └── vitest.d.ts
├── tsconfig.json
├── vitest.config.ts
└── wrangler.toml.example
================================================
FILE CONTENTS
================================================
================================================
FILE: .dev.vars.example
================================================
# This file is used to set secrets for running a local dev server via npm run dev as well as tests
# Use a valid fake PKCS8 key for apple oauth
JWT_SECRET = "iamarandomjwtsecret"
DATABASE_PASSWORD = "database password"
AWS_ACCESS_KEY_ID = "realorfake"
AWS_SECRET_ACCESS_KEY = "realorfake"
SENTRY_DSN = "realorempty"
OAUTH_GITHUB_CLIENT_SECRET = "realorfake"
OAUTH_DISCORD_CLIENT_SECRET = "realorfake"
OAUTH_SPOTIFY_CLIENT_SECRET = "realorfake"
OAUTH_GOOGLE_CLIENT_SECRET = "realorfake"
OAUTH_FACEBOOK_CLIENT_SECRET = "realorfake"
OAUTH_APPLE_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCFUxrQFuI9oTnQb8SeYBGJkiVeB85Xare9anUaKhRTyuP83n/UxBTFpEc/DTFwy3o/7OKvqLhhBhbrinsfjV7ZXMJJzPEDeMcgdFfwaooGapEJMEb9QkjfXAXidffyDgxeFXvs0nd1IWWTVKnou6859FyNGabhRN8eA+syOgZk4RfAPwiXZl5q+jDdHKrH/fVKhgkihlGbZFHevEHvHLE1ZxLTcMWxQjOUIyS86o+N6hHWTNJsSmo7apYDkEjQQNosegq50xTmml6NcjwtV2v9tVzaCeGAOl9IyJfhHRBYPt16v/DqpbWCd4S9REut79GJR1lW5ZZm0stpDD7myM3BAgMBAAECggEAAR7TaxvCH3f3IyoJSjZu90u/3iQtJv1p2WDnZoajgJfEJjgddWWHcijBA4XiHDtNhfOA7S57DC+vqh+SDNAUk7mMlW+wN9IARGTN52KR0d975AqgkhjIQX5Fu2M35/QXxQOjtLgJEnYrIxuTSPYo0REdZP8p8JsyT8+DHrsvmhHqFQHszNEjnZ33/JAFelOUJgbeisHRv99Voiv2qQi4al//iZ00NKrRvkRA417Mim1cwhkSnbzUXdhLAH4MInGzizmQVpHlzSYVV1vzXqYXH0K8arMY60fCqf9zxKwYCbfEJjnAREof9vs19V9wnHtbI9rcODEfRz5I/61oLrGl0QKBgQC5wuQiUjFX3GKUXuVOvvMZIb8Zk5T8QPhamZDHHlx3v3vZ8srlQ8tSSqaMfgSYuSzYDi4LAOI1nZtBHp33D4XZZQKLql3cA9i0xrJtHim6ep2w+7if0mr57v+sgG16eS3AHiTknYilzO56MSzaOewlgHevcw4IhoNlFEyDAECQKQKBgQC3vIJh6phpj5lOY3xAGb/rVUZ/PtNZco2ppGm6Q7u3RDkey55oNxGXOXOw8Jrv3qlieNSoId/ZMrX0Mfe41K2HYQo78qVGWA+kOagc3tsP0uAK9EYLs034HTAaJof6XDHUgUGwmLJ/zDrc9Lk1S5cUZ6Js26o/gBASVQZzsLEj2QKBgQCm6BLdN6a4P/+fOoiksXNx4F15WJ5j7Oh5V0O7dW819SoOEVX2m2xje0mcMFpm8vL1CgCayGd4Ly1hXGYop5znURfxb9k3p4keHO4Slyh9MlDfxb0EdSbDfNfjId28Tocp+KvDcjxmZPTde7PGPIcOxxhC34j7ZglHV+7LQf3AyQKBgBPsCq8XQsNfYJ4RR22j3R1lN6mgZEY0l4unWhdqNLZgXVkrdteR8QRWpGaxD/umRvN4aoZ4dc8VIomByXxvAwnEydlKLAV+kuOZpNLMjzAeC1Dkv5uRK4kVkRukxeWtjXGfOkItrF0TBebjWhmfQphhzEjFYKZV+mgic/qjU/GxAoGBALQlMAXKKPrt/nrAcTTjQhPNhTKKRhkcICdTVZhbKkHRbwBiMVsPUvDualQpkflzYEdf+mBxz6g9Gr4S8scY/1CljlH+SHK1QA7seGXlQ3+saY2PLCDtV8cuZO4ggV0tbTmR8RQCMNO0HUjfSD4vkwmnu8nYbI8TalKFG3t8NrV2-----END PRIVATE KEY-----"
================================================
FILE: .env.example
================================================
# Please note that this file is only used for running migrations for a development database
# The rest of the variables for the development environment are setup in the wrangler.toml file
DATABASE_NAME='name'
DATABASE_USERNAME='username'
DATABASE_HOST='host'
DATABASE_PASSWORD='password'
================================================
FILE: .env.test.example
================================================
# Please note that aws credentials and oauth credentials don't have to work only planetscale
# The apple oauth private key must be in the pkcs8 format
# credentials are required to run the tests.
ENV = 'test'
JWT_SECRET='iamasecret'
JWT_ACCESS_EXPIRATION_MINUTES=30
JWT_REFRESH_EXPIRATION_DAYS=30
JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15
DATABASE_NAME='name'
DATABASE_USERNAME='username'
DATABASE_HOST='host'
DATABASE_PASSWORD='password'
AWS_ACCESS_KEY_ID='test'
AWS_SECRET_ACCESS_KEY='test'
AWS_REGION='eu-west-1'
EMAIL_SENDER='noreply@dictionaryapi.io'
SENTRY_DSN=''
OAUTH_WEB_REDIRECT_URL='https://frontend.com/login'
OAUTH_IOS_REDIRECT_URL='app://login'
OAUTH_ANDROID_REDIRECT_URL='app://login'
OAUTH_GITHUB_CLIENT_ID='myclientid'
OAUTH_GITHUB_CLIENT_SECRET='myclientsecret'
OAUTH_DISCORD_CLIENT_ID='myclientid'
OAUTH_DISCORD_CLIENT_SECRET='myclientsecret'
OAUTH_SPOTIFY_CLIENT_ID='myclientid'
OAUTH_SPOTIFY_CLIENT_SECRET='myclientsecret'
OAUTH_GOOGLE_CLIENT_ID='myclientid'
OAUTH_GOOGLE_CLIENT_SECRET='myclientsecret'
OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login'
OAUTH_FACEBOOK_CLIENT_ID='myclientid'
OAUTH_FACEBOOK_CLIENT_SECRET='myclientsecret'
OAUTH_APPLE_CLIENT_ID='myclientid'
OAUTH_APPLE_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----'
OAUTH_APPLE_KEY_ID='mykeyid'
OAUTH_APPLE_TEAM_ID='myteamid'
OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30
OAUTH_APPLE_REDIRECT_URL='https://frontend.com/login'
================================================
FILE: .eslintignore
================================================
node_modules
dist
================================================
FILE: .gitignore
================================================
/target
/dist
**/*.rs.bk
pkg/
wasm-pack.log
worker/
node_modules/
.vscode
.env*
!*.example
.mf
wrangler.toml
coverage
.vscode
pnpm-lock.yaml
.dev.vars
================================================
FILE: .husky/pre-commit
================================================
npm run lint
================================================
FILE: .husky/pre-push
================================================
npm run tests:coverage
================================================
FILE: .prettierignore
================================================
node_modules
dist
================================================
FILE: .prettierrc
================================================
{
"singleQuote": true,
"semi": false,
"trailingComma": "none",
"tabWidth": 2,
"printWidth": 100,
"bracketSpacing": true
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Ben Louis Armstrong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# RESTful API Cloudflare Workers Boilerplate
A boilerplate/starter project for quickly building RESTful APIs using
[Cloudflare Workers](https://workers.cloudflare.com/), [Hono](https://honojs.dev/), and
[PlanetScale](https://planetscale.com/). Inspired by
[node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate) by hagopj13.
## Quick Start
To create a project, simply run:
```bash
npx create-cf-planetscale-app <project-name>
```
Or
```bash
npm init cf-planetscale-app <project-name>
```
## Table of Contents
- [RESTful API Cloudflare Workers Boilerplate](#restful-api-cloudflare-workers-boilerplate)
- [Quick Start](#quick-start)
- [Table of Contents](#table-of-contents)
- [Features](#features)
- [Commands](#commands)
- [Error Handling](#error-handling)
- [Validation](#validation)
- [Authentication](#authentication)
- [Emails](#emails)
- [Authorisation](#authorisation)
- [Rate Limiting](#rate-limiting)
- [Contributing](#contributing)
- [Inspirations](#inspirations)
- [License](#license)
## Features
- **SQL database**: [PlanetScale](https://planetscale.com/) using
[Kysely](https://github.com/koskimas/kysely) as a type-safe SQl query builder
- **Authentication and authorization**: using JWT
- **Validation**: request data validation using [Zod](https://github.com/colinhacks/zod)
- **Logging**: using [Sentry](https://sentry.io/)
- **Testing**: unit and integration tests using [Vitest](https://vitest.dev/)
- **Error handling**: centralised error handling mechanism provided by [Hono](https://honojs.dev/)
- **Git hooks**: with [Husky](https://github.com/typicode/husky)
- **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io)
- **Emails**: with [Amazon SES](https://aws.amazon.com/ses/)
- **Oauth**: Support for Discord, Github, Spotify, Google, Apple and Facebook. Support coming for
Instagram and Twitter
- **Rate Limiting**: using Cloudflare durable objects you can rate limit endpoints usin the sliding
window algorithm
## Commands
Running locally:
```bash
npm run dev
```
Testing:
```bash
# run all tests
npm run tests
# run test coverage
npm run tests:coverage
```
Linting:
```bash
# run ESLint
npm run lint
# fix ESLint errors
npm run lint:fix
# run prettier
npm run prettier
# fix prettier errors
npm run prettier:fix
```
Migrations:
To deploy to production you must first deploy to a test/dev branch on Planetscale and then create
a deploy request and merge the schema into production.
```bash
# run all migrations for testing
npm run migrate:test:latest
# remove all migrations for testing
npm run migrate:test:none
# revert last migration for testing
npm run migrate:test:down
```
Deploy to Cloudflare:
```bash
npm run deploy
npm run deploy
```
## Error Handling
The app has a centralized error handling mechanism provided by [Hono](https://honojs.dev/).
```javascript
app.onError(errorHandler)
```
All errors will be caught by the errorHandler which converts the error to an ApiError and formats
it in a JSON response. Any errors that aren't intentionally thrown, e.g. 500 errors, are logged to
Sentry.
The error handling middleware sends an error response, which has the following format:
```json
{
"code": 404,
"message": "Not found"
}
```
When running in development mode, the error response also contains the error stack.
## Validation
Request data is validated using [Zod](https://github.com/colinhacks/zod).
The validation schemas are defined in the `src/validations` directory and are used in the
controllers by getting either the query or body and then calling the parse on the relevant
validation function:
```javascript
const getUsers: Handler<{ Bindings: Bindings }> = async (c) => {
const config = getConfig(c.env)
const queryParse = c.req.query()
const query = userValidation.getUsers.parse(queryParse)
const filter = { email: query.email }
const options = { sortBy: query.sort_by, limit: query.limit, page: query.page }
const result = await userService.queryUsers(filter, options, config.database)
return c.json(result, httpStatus.OK)
}
```
## Authentication
To require authentication for certain routes, you can use the `auth` middleware.
```javascript
import { Hono } from 'hono'
import * as userController from '../controllers/user.controller'
import { auth } from '../middlewares/auth'
const route = new Hono<{ Bindings: Bindings }>()
route.post('/', auth(), userController.createUser)
export { route }
```
These routes require a valid JWT access token in the Authorization request header using the Bearer
schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown.
## Emails
Support for Email sending using [Amazon SES](https://aws.amazon.com/ses/). Just call the `sendEmail`
function in `src/services/email.service.ts`:
```javascript
const sendResetPasswordEmail = async (email: string, emailData: EmailData, config: Config) => {
const message = {
Subject: {
Data: 'Reset your password',
Charset: 'UTF-8'
},
Body: {
Text: {
Charset: 'UTF-8',
Data: `
Hello ${emailData.name}
Please reset your password by clicking the following link:
${emailData.token}
`
}
}
}
await sendEmail(email, config.email.sender, message, config.aws)
}
```
## Authorisation
The `auth` middleware can also be used to require certain rights/permissions to access a route.
```javascript
import { Hono } from 'hono'
import * as userController from '../controllers/user.controller'
import { auth } from '../middlewares/auth'
const route = new Hono<{ Bindings: Bindings }>()
route.post('/', auth('manageUsers'), userController.createUser)
export { route }
```
In the example above, an authenticated user can access this route only if that user has the
`manageUsers` permission.
The permissions are role-based. You can view the permissions/rights of each role in the
`src/config/roles.ts` file.
If the user making the request does not have the required permissions to access this route, a
Forbidden (403) error is thrown.
## Rate Limiting
To apply rate limits for certain routes, you can use the `rateLimit` middleware.
```javascript
import { Hono } from 'hono'
import { Environment } from '../../bindings'
import { auth } from '../middlewares/auth'
import { rateLimit } from '../middlewares/rateLimiter'
export const route = new Hono<Environment>()
const twoMinutes = 120
const oneRequest = 1
route.post(
'/send-verification-email',
auth(),
rateLimit(twoMinutes, oneRequest),
authController.sendVerificationEmail
)
```
This uses Cloudflare durable objects to apply rate limits using the sliding window algorithm. You
can specify the interval size in seconds and how many requests are allowed per interval.
If the rate limit is hit a `429` will be returned to the client.
These headers are returned with each endpoint that has rate limiting applied:
* `X-RateLimit-Limit` - How many requests are allowed per window
* `X-RateLimit-Reset` - How many seconds until the current window resets
* `X-RateLimit-Policy` - Details about the rate limit policy in this format `${limit};w=${interval};comment="Sliding window"`
* `X-RateLimit-Remaining` - How many requests you can send until you will be rate limited. Please
note this doesn't just reset to the limit when the reset period hits. Use it as indicator of your
current throughput e.g. if you have 12 requests allowed every 1 second and remaining is 0
you are at 100% throughput, but if it is 6 you are 50% throughput. This value constantly changes
as the window progresses either increasing or decreasing based on your throughput
The rate limit will be based on IP unless the user is authenticated then it will be based on the
user ID.
## Contributing
Contributions are more than welcome!
## Inspirations
- [hagopj13/node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate)
## License
[MIT](LICENSE)
================================================
FILE: TODO.md
================================================
### Todo
- [ ] Flesh out README
- [ ] Oauth
- [ ] Add support for Twitter
- [ ] API docs
- [ ] Fix all types
- [ ] CI/CD using Github Actions
- [ ] MFA
- [ ] 100% test coverage
- [ ] Unit tests
### In Progress
================================================
FILE: bin/createApp.js
================================================
#!/usr/bin/env node
/* eslint no-console: "off" */
import { exec as child_exec } from 'child_process'
import fs from 'fs'
import path from 'path'
import util from 'util'
// Utility functions
const exec = util.promisify(child_exec)
const runCmd = async (command) => {
try {
const { stdout, stderr } = await exec(command)
console.log(stdout)
console.log(stderr)
} catch(err) {
console.log(err)
}
}
// Validate arguments
if (process.argv.length < 3) {
console.log('Please specify the target project directory.')
console.log('For example:')
console.log(' npx create-cf-planetscale-app my-app')
console.log(' OR')
console.log(' npm init create-cf-planetscale-app my-app')
process.exit(1)
}
// Define constants
const ownPath = process.cwd()
const folderName = process.argv[2]
const appPath = path.join(ownPath, folderName)
const repo = 'https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate'
// Check if directory already exists
try {
fs.mkdirSync(appPath)
} catch (err) {
if (err.code === 'EEXIST') {
console.log('Directory already exists. Please choose another name for the project.')
} else {
console.log(err)
}
process.exit(1)
}
const setup = async () => {
try {
// Clone repo
console.log(`Downloading files from repo ${repo}`)
await runCmd(`git clone --depth 1 ${repo} ${folderName}`)
console.log('Cloned successfully.')
console.log('')
// Change directory
process.chdir(appPath)
// Install dependencies
console.log('Installing dependencies...')
await runCmd('npm install')
console.log('Dependencies installed successfully.')
console.log()
// Copy wrangler.toml
fs.copyFileSync(
path.join(appPath, 'wrangler.toml.example'),
path.join(appPath, 'wrangler.toml')
)
console.log('wrangler.toml copied.')
// Delete .git folder
await runCmd('npx rimraf ./.git')
// Remove extra files
fs.unlinkSync(path.join(appPath, 'TODO.md'))
fs.unlinkSync(path.join(appPath, 'bin', 'createApp.js'))
fs.rmdirSync(path.join(appPath, 'bin'))
console.log('Installation is now complete!')
console.log()
console.log('Enjoy your production-ready Cloudflare Workers project!')
console.log('Check README.md for more info.')
} catch (error) {
console.log(error)
}
}
setup()
================================================
FILE: bindings.d.ts
================================================
import type { JwtPayload } from '@tsndr/cloudflare-worker-jwt'
import type { Toucan } from 'toucan-js'
import type { RateLimiter } from './src/durable-objects/rate-limiter.do'
type Environment = {
Bindings: {
ENV: string
JWT_SECRET: string
JWT_ACCESS_EXPIRATION_MINUTES: number
JWT_REFRESH_EXPIRATION_DAYS: number
JWT_RESET_PASSWORD_EXPIRATION_MINUTES: number
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: number
DATABASE_NAME: string
DATABASE_USERNAME: string
DATABASE_PASSWORD: string
DATABASE_HOST: string
RATE_LIMITER: DurableObjectNamespace<RateLimiter>
SENTRY_DSN: string
AWS_ACCESS_KEY_ID: string
AWS_SECRET_ACCESS_KEY: string
AWS_REGION: string
EMAIL_SENDER: string
OAUTH_WEB_REDIRECT_URL: string
OAUTH_ANDROID_REDIRECT_URL: string
OAUTH_IOS_REDIRECT_URL: string
OAUTH_GITHUB_CLIENT_ID: string
OAUTH_GITHUB_CLIENT_SECRET: string
OAUTH_GOOGLE_CLIENT_ID: string
OAUTH_GOOGLE_CLIENT_SECRET: string
OAUTH_DISCORD_CLIENT_ID: string
OAUTH_DISCORD_CLIENT_SECRET: string
OAUTH_SPOTIFY_CLIENT_ID: string
OAUTH_SPOTIFY_CLIENT_SECRET: string
OAUTH_FACEBOOK_CLIENT_ID: string
OAUTH_FACEBOOK_CLIENT_SECRET: string
OAUTH_APPLE_CLIENT_ID: string
OAUTH_APPLE_KEY_ID: string
OAUTH_APPLE_TEAM_ID: string
OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: number
OAUTH_APPLE_PRIVATE_KEY: string
}
Variables: {
payload: JwtPayload
sentry: Toucan
}
}
================================================
FILE: build.js
================================================
import { build } from 'esbuild'
try {
await build({
entryPoints: ['./src/index.ts'],
bundle: true,
outdir: './dist/',
sourcemap: true,
minify: true,
conditions: ['worker', 'browser'],
outExtension: { '.js': '.mjs' },
format: 'esm',
target: 'esnext'
})
} catch {
process.exitCode = 1
}
================================================
FILE: eslint.config.js
================================================
import eslint from '@eslint/js'
import importx from 'eslint-plugin-import-x'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import vitest from 'eslint-plugin-vitest'
import globals from 'globals'
import tseslint from 'typescript-eslint'
const defaultFiles = [
'src/**',
'tests/**',
'bindings.d.ts',
'scripts/**',
'migrations/**'
]
const config = {
languageOptions: {
sourceType: 'module',
ecmaVersion: 2021,
globals: {
...globals.node,
...globals.browser,
...globals.serviceworker,
fetch: 'readonly',
Response: 'readonly',
Request: 'readonly',
addEventListener: 'readonly',
ENV: 'readonly'
},
},
plugins: { 'import-x': importx },
rules: {
quotes: ['error', 'single'],
'no-console': 'error',
'sort-imports': 'off',
'import-x/order': [
'error',
{
alphabetize: { order: 'asc' },
}
],
'node/no-missing-import': 'off',
'node/no-missing-require': 'off',
'node/no-deprecated-api': 'off',
'node/no-unpublished-import': 'off',
'node/no-unpublished-require': 'off',
'node/no-unsupported-features/es-syntax': 'off',
semi: ['error', 'never'],
'no-debugger': ['error'],
'no-empty': ['warn', { allowEmptyCatch: true }],
'no-process-exit': 'off',
'no-useless-escape': 'off',
'max-len': ['error', { code: 100 }],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
files: defaultFiles
}
export const testConfig = {
...vitest.configs.recommended,
plugins: { vitest: vitest },
files: ['tests/**']
}
export default tseslint.config(
{
ignores: ['dist/', 'coverage/', 'node_modules/'],
},
{
files: defaultFiles,
...eslint.configs.recommended
},
...tseslint.configs.recommended,
config,
testConfig,
{
files: defaultFiles,
...eslintPluginPrettierRecommended
}
)
================================================
FILE: jest.config.js
================================================
export default {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts'],
clearMocks: true,
globals: {
'ts-jest': {
tsconfig: 'tests/tsconfig.json',
useESM: true,
isolatedModules: true,
},
},
testEnvironment: 'miniflare',
testEnvironmentOptions: {
scriptPath: 'dist/index.mjs',
modules: true
},
transformIgnorePatterns: [
'node_modules/(?!(@planetscale|kysely-planetscale|@aws-sdk|worker-auth-providers|uuid))'
],
moduleNameMapper: {'^uuid$': 'uuid'},
collectCoverageFrom: ['src/**/*.{ts,js}'],
coveragePathIgnorePatterns: [
'src/durable-objects' // Jest doesn't accurately report coverage for Durable Objects
],
testTimeout: 20000
}
================================================
FILE: migrations/01_initial.ts
================================================
import { Kysely, sql } from 'kysely'
import { Database } from '../src/config/database'
export async function up(db: Kysely<Database>) {
await db.schema
.createTable('user')
.addColumn('id', 'varchar(21)', (col) => col.primaryKey())
.addColumn('name', 'varchar(255)')
.addColumn('password', 'varchar(255)')
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('is_email_verified', 'boolean', (col) => col.defaultTo(false))
.addColumn('role', 'varchar(255)', (col) => col.defaultTo('user'))
.addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`))
.addColumn('updated_at', 'timestamp', (col) => {
return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`)
})
.execute()
await db.schema
.createTable('authorisations')
.addColumn('provider_type', 'varchar(255)', (col) => col.notNull())
.addColumn('provider_user_id', 'varchar(255)', (col) => col.notNull())
.addColumn('user_id', 'varchar(255)', (col) => col.notNull())
.addPrimaryKeyConstraint('primary_key', ['provider_type', 'provider_user_id', 'user_id'])
.addUniqueConstraint('unique_provider_user', ['provider_type', 'provider_user_id'])
.addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`))
.addColumn('updated_at', 'timestamp', (col) => {
return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`)
})
.execute()
await db.schema.createIndex('user_email_index').on('user').column('email').execute()
await db.schema
.createIndex('authorisations_user_id_index')
.on('authorisations')
.column('user_id')
.execute()
await db.schema
.createTable('one_time_oauth_code')
.addColumn('code', 'varchar(255)', (col) => col.primaryKey())
.addColumn('user_id', 'varchar(255)', (col) => col.notNull())
.addColumn('access_token', 'varchar(255)', (col) => col.notNull())
.addColumn('access_token_expires_at', 'timestamp', (col) => col.notNull())
.addColumn('refresh_token', 'varchar(255)', (col) => col.notNull())
.addColumn('refresh_token_expires_at', 'timestamp', (col) => col.notNull())
.addColumn('expires_at', 'timestamp', (col) => col.notNull())
.addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`))
.addColumn('updated_at', 'timestamp', (col) => {
return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`)
})
.execute()
}
export async function down(db: Kysely<Database>) {
await db.schema.dropTable('user').ifExists().execute()
await db.schema.dropTable('authorisations').ifExists().execute()
await db.schema.dropTable('one_time_oauth_code').ifExists().execute()
}
================================================
FILE: package.json
================================================
{
"name": "create-cf-planetscale-app",
"version": "3.0.0",
"description": "Create a Cloudflare workers app for building production ready RESTful APIs using Hono",
"main": "dist/index.mjs",
"engines": {
"node": ">=12.0.0"
},
"bin": "bin/createApp.js",
"repository": "https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate.git",
"author": "Ben Louis Armstrong <ben.armstrong22@gmail.com>",
"license": "MIT",
"keywords": [
"cloudflare",
"workers",
"cloudflare-worker",
"cloudflare-workers",
"planetscale",
"boilerplate",
"template",
"starter",
"example",
"vitest",
"hono",
"api",
"rest",
"sql",
"oauth",
"jwt",
"es6",
"es7",
"es8",
"es9",
"jwt",
"zod",
"eslint",
"prettier"
],
"scripts": {
"build": "node ./build.js",
"dev": "wrangler dev dist/index.mjs --live-reload --port 8787",
"tests": "npm run build && vitest run",
"tests:coverage": "npm run build && vitest run --coverage --coverage.provider istanbul --coverage.include src/",
"migrate:test:latest": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test latest",
"migrate:test:none": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test none",
"migrate:test:down": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test down",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prettier": "prettier --check **/*.ts",
"prettier:fix": "prettier --write **/**/*.ts",
"prepare": "husky",
"deploy": "wrangler publish"
},
"type": "module",
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.4.25",
"@cloudflare/workers-types": "^4.20240821.1",
"@faker-js/faker": "^8.4.1",
"@types/bcryptjs": "^2.4.6",
"@types/eslint__js": "^8.42.3",
"@typescript-eslint/parser": "^8.2.0",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"esbuild": "^0.23.1",
"eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import-x": "^3.1.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vitest": "^0.5.4",
"globals": "^15.9.0",
"husky": "^9.1.5",
"mockdate": "^3.0.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"typescript-eslint": "^8.2.0",
"vitest": "1.5.0",
"wrangler": "^3.72.2"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.637.0",
"@hono/sentry": "^1.2.0",
"@planetscale/database": "^1.19.0",
"@smithy/types": "^3.3.0",
"@tsndr/cloudflare-worker-jwt": "2.5.3",
"@vitest/coverage-istanbul": "^1.5.0",
"bcryptjs": "^2.4.3",
"dayjs": "^1.11.13",
"hono": "^4.5.8",
"http-status": "^1.7.4",
"kysely": "^0.27.4",
"kysely-planetscale": "^1.5",
"nanoid": "^5.0.7",
"toucan-js": "4.0.0",
"worker-auth-providers": "^0.0.13",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.1"
}
}
================================================
FILE: scripts/migrate.ts
================================================
/* eslint no-console: "off" */
import { promises as fs } from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import * as dotenv from 'dotenv'
import { Migrator, FileMigrationProvider, NO_MIGRATIONS } from 'kysely'
import { Kysely } from 'kysely'
import { PlanetScaleDialect } from 'kysely-planetscale'
import { User } from '../src/models/user.model'
const envFile = {
dev: '.env',
test: '.env.test'
}
const __filename = fileURLToPath(import.meta.url)
dotenv.config({ path: path.join(path.dirname(__filename), `../${envFile[process.argv[2]]}`) })
interface Database {
user: User
}
const db = new Kysely<Database>({
dialect: new PlanetScaleDialect({
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
host: process.env.DATABASE_HOST
})
})
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(path.dirname(__filename), '../migrations')
})
})
async function migrateToLatest() {
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration '${it.migrationName}' was executed successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error('failed to migrate')
console.error(error)
process.exit(1)
}
await db.destroy()
}
async function migrateDown() {
const { error, results } = await migrator.migrateDown()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration '${it.migrationName}' was reverted successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error('failed to migrate')
console.error(error)
process.exit(1)
}
await db.destroy()
}
async function migrateNone() {
const { error, results } = await migrator.migrateTo(NO_MIGRATIONS)
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration '${it.migrationName}' was reverted successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error('failed to migrate')
console.error(error)
process.exit(1)
}
await db.destroy()
}
const myArgs = process.argv[3]
if (myArgs === 'down') {
await migrateDown()
} else if (myArgs === 'latest') {
await migrateToLatest()
} else if (myArgs === 'none') {
await migrateNone()
}
================================================
FILE: src/config/authProviders.ts
================================================
export const authProviders = {
GITHUB: 'github',
SPOTIFY: 'spotify',
DISCORD: 'discord',
GOOGLE: 'google',
FACEBOOK: 'facebook',
APPLE: 'apple'
} as const
================================================
FILE: src/config/config.ts
================================================
import httpStatus from 'http-status'
import { ZodError, z } from 'zod'
import { Environment } from '../../bindings'
import { ApiError } from '../utils/api-error'
import { generateZodErrorMessage } from '../utils/zod'
const envVarsSchema = z.object({
ENV: z.union([z.literal('production'), z.literal('development'), z.literal('test')]),
DATABASE_NAME: z.string(),
DATABASE_USERNAME: z.string(),
DATABASE_PASSWORD: z.string(),
DATABASE_HOST: z.string(),
JWT_SECRET: z.string(),
JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30),
JWT_REFRESH_EXPIRATION_DAYS: z.coerce.number().default(30),
JWT_RESET_PASSWORD_EXPIRATION_MINUTES: z.coerce.number().default(10),
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: z.coerce.number().default(10),
AWS_ACCESS_KEY_ID: z.string(),
AWS_SECRET_ACCESS_KEY: z.string(),
AWS_REGION: z.string(),
EMAIL_SENDER: z.string(),
OAUTH_WEB_REDIRECT_URL: z.string(),
OAUTH_ANDROID_REDIRECT_URL: z.string(),
OAUTH_IOS_REDIRECT_URL: z.string(),
OAUTH_GITHUB_CLIENT_ID: z.string(),
OAUTH_GITHUB_CLIENT_SECRET: z.string(),
OAUTH_GOOGLE_CLIENT_ID: z.string(),
OAUTH_GOOGLE_CLIENT_SECRET: z.string(),
OAUTH_DISCORD_CLIENT_ID: z.string(),
OAUTH_DISCORD_CLIENT_SECRET: z.string(),
OAUTH_SPOTIFY_CLIENT_ID: z.string(),
OAUTH_SPOTIFY_CLIENT_SECRET: z.string(),
OAUTH_FACEBOOK_CLIENT_ID: z.string(),
OAUTH_FACEBOOK_CLIENT_SECRET: z.string(),
OAUTH_APPLE_CLIENT_ID: z.string(),
OAUTH_APPLE_PRIVATE_KEY: z.string(),
OAUTH_APPLE_KEY_ID: z.string(),
OAUTH_APPLE_TEAM_ID: z.string(),
OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30),
OAUTH_APPLE_REDIRECT_URL: z.string()
})
export type EnvVarsSchemaType = z.infer<typeof envVarsSchema>
export interface Config {
env: 'production' | 'development' | 'test'
database: {
name: string
username: string
password: string
host: string
}
jwt: {
secret: string
accessExpirationMinutes: number
refreshExpirationDays: number
resetPasswordExpirationMinutes: number
verifyEmailExpirationMinutes: number
}
aws: {
accessKeyId: string
secretAccessKey: string
region: string
}
email: {
sender: string
}
oauth: {
platform: {
web: {
redirectUrl: string
}
android: {
redirectUrl: string
}
ios: {
redirectUrl: string
}
}
provider: {
github: {
clientId: string
clientSecret: string
}
google: {
clientId: string
clientSecret: string
}
spotify: {
clientId: string
clientSecret: string
}
discord: {
clientId: string
clientSecret: string
}
facebook: {
clientId: string
clientSecret: string
}
apple: {
clientId: string
privateKey: string
keyId: string
teamId: string
jwtAccessExpirationMinutes: number
redirectUrl: string
}
}
}
}
let config: Config
export const getConfig = (env: Environment['Bindings']) => {
if (config) {
return config
}
let envVars: EnvVarsSchemaType
try {
envVars = envVarsSchema.parse(env)
} catch (err) {
if (env.ENV && env.ENV === 'production') {
throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Invalid server configuration')
}
if (err instanceof ZodError) {
const errorMessage = generateZodErrorMessage(err)
throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, errorMessage)
}
throw err
}
config = {
env: envVars.ENV,
database: {
name: envVars.DATABASE_NAME,
username: envVars.DATABASE_USERNAME,
password: envVars.DATABASE_PASSWORD,
host: envVars.DATABASE_HOST
},
jwt: {
secret: envVars.JWT_SECRET,
accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES,
refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS,
resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES,
verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES
},
aws: {
accessKeyId: envVars.AWS_ACCESS_KEY_ID,
secretAccessKey: envVars.AWS_SECRET_ACCESS_KEY,
region: envVars.AWS_REGION
},
email: {
sender: envVars.EMAIL_SENDER
},
oauth: {
platform: {
web: {
redirectUrl: envVars.OAUTH_WEB_REDIRECT_URL
},
android: {
redirectUrl: envVars.OAUTH_ANDROID_REDIRECT_URL
},
ios: {
redirectUrl: envVars.OAUTH_IOS_REDIRECT_URL
}
},
provider: {
github: {
clientId: envVars.OAUTH_GITHUB_CLIENT_ID,
clientSecret: envVars.OAUTH_GITHUB_CLIENT_SECRET
},
google: {
clientId: envVars.OAUTH_GOOGLE_CLIENT_ID,
clientSecret: envVars.OAUTH_GOOGLE_CLIENT_SECRET
},
spotify: {
clientId: envVars.OAUTH_SPOTIFY_CLIENT_ID,
clientSecret: envVars.OAUTH_SPOTIFY_CLIENT_SECRET
},
discord: {
clientId: envVars.OAUTH_DISCORD_CLIENT_ID,
clientSecret: envVars.OAUTH_DISCORD_CLIENT_SECRET
},
facebook: {
clientId: envVars.OAUTH_FACEBOOK_CLIENT_ID,
clientSecret: envVars.OAUTH_FACEBOOK_CLIENT_SECRET
},
apple: {
clientId: envVars.OAUTH_APPLE_CLIENT_ID,
privateKey: envVars.OAUTH_APPLE_PRIVATE_KEY,
keyId: envVars.OAUTH_APPLE_KEY_ID,
teamId: envVars.OAUTH_APPLE_TEAM_ID,
jwtAccessExpirationMinutes: envVars.OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES,
redirectUrl: envVars.OAUTH_APPLE_REDIRECT_URL
}
}
}
}
return config
}
================================================
FILE: src/config/database.ts
================================================
import { Kysely } from 'kysely'
import { PlanetScaleDialect } from 'kysely-planetscale'
import { AuthProviderTable } from '../tables/oauth.table'
import { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table'
import { UserTable } from '../tables/user.table'
import { Config } from './config'
let dbClient: Kysely<Database>
export interface Database {
user: UserTable
authorisations: AuthProviderTable
one_time_oauth_code: OneTimeOauthCodeTable
}
export const getDBClient = (databaseConfig: Config['database']): Kysely<Database> => {
dbClient =
dbClient ||
new Kysely<Database>({
dialect: new PlanetScaleDialect({
username: databaseConfig.username,
password: databaseConfig.password,
host: databaseConfig.host,
fetch: (url, init) => {
// TODO: REMOVE.
// Remove cache header
// https://github.com/cloudflare/workerd/issues/698
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (init as any)['cache']
return fetch(url, init)
}
})
})
return dbClient
}
================================================
FILE: src/config/roles.ts
================================================
export const roleRights = {
user: [],
admin: ['getUsers', 'manageUsers']
} as const
export const roles = Object.keys(roleRights) as Role[]
export type Permission = (typeof roleRights)[keyof typeof roleRights][number]
export type Role = keyof typeof roleRights
================================================
FILE: src/config/tokens.ts
================================================
export const tokenTypes = {
ACCESS: 'access',
REFRESH: 'refresh',
RESET_PASSWORD: 'resetPassword',
VERIFY_EMAIL: 'verifyEmail'
} as const
export type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes]
================================================
FILE: src/controllers/auth/auth.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { Environment } from '../../../bindings'
import { getConfig } from '../../config/config'
import * as authService from '../../services/auth.service'
import * as emailService from '../../services/email.service'
import * as tokenService from '../../services/token.service'
import * as userService from '../../services/user.service'
import { ApiError } from '../../utils/api-error'
import * as authValidation from '../../validations/auth.validation'
export const register: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const body = await authValidation.register.parseAsync(bodyParse)
const user = await authService.register(body, config.database)
const tokens = await tokenService.generateAuthTokens(user, config.jwt)
return c.json({ user, tokens }, httpStatus.CREATED)
}
export const login: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { email, password } = authValidation.login.parse(bodyParse)
const user = await authService.loginUserWithEmailAndPassword(email, password, config.database)
const tokens = await tokenService.generateAuthTokens(user, config.jwt)
return c.json({ user, tokens }, httpStatus.OK)
}
export const refreshTokens: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { refresh_token } = authValidation.refreshTokens.parse(bodyParse)
const tokens = await authService.refreshAuth(refresh_token, config)
return c.json({ ...tokens }, httpStatus.OK)
}
export const forgotPassword: Handler<Environment> = async (c) => {
const bodyParse = await c.req.json()
const config = getConfig(c.env)
const { email } = authValidation.forgotPassword.parse(bodyParse)
const user = await userService.getUserByEmail(email, config.database)
// Don't let bad actors know if the email is registered by throwing if the user exists
if (user) {
const resetPasswordToken = await tokenService.generateResetPasswordToken(user, config.jwt)
await emailService.sendResetPasswordEmail(
user.email,
{ name: user.name || '', token: resetPasswordToken },
config
)
}
c.status(httpStatus.NO_CONTENT)
return c.body(null)
}
export const resetPassword: Handler<Environment> = async (c) => {
const queryParse = c.req.query()
const bodyParse = await c.req.json()
const config = getConfig(c.env)
const { query, body } = await authValidation.resetPassword.parseAsync({
query: queryParse,
body: bodyParse
})
await authService.resetPassword(query.token, body.password, config)
c.status(httpStatus.NO_CONTENT)
return c.body(null)
}
export const sendVerificationEmail: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const payload = c.get('payload')
const userId = payload.sub
if (!userId) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
// Don't let bad actors know if the email is registered by returning an error if the email
// is already verified
try {
const user = await userService.getUserById(userId, config.database)
if (!user || user.is_email_verified) {
throw new Error()
}
const verifyEmailToken = await tokenService.generateVerifyEmailToken(user, config.jwt)
await emailService.sendVerificationEmail(
user.email,
{ name: user.name || '', token: verifyEmailToken },
config
)
} catch {}
c.status(httpStatus.NO_CONTENT)
return c.body(null)
}
export const verifyEmail: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const queryParse = c.req.query()
const { token } = authValidation.verifyEmail.parse(queryParse)
await authService.verifyEmail(token, config)
c.status(httpStatus.NO_CONTENT)
return c.body(null)
}
export const getAuthorisations: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const payload = c.get('payload')
if (!payload.sub) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const userId = payload.sub
const authorisations = await userService.getAuthorisations(userId, config.database)
return c.json(authorisations, httpStatus.OK)
}
================================================
FILE: src/controllers/auth/oauth/apple.controller.ts
================================================
// TODO: Handle users using private email relay
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/
// authenticating_users_with_sign_in_with_apple
// Also handle users without email
// refactor
import { decode } from '@tsndr/cloudflare-worker-jwt'
import { Handler } from 'hono'
import type { StatusCode } from 'hono/utils/http-status'
import httpStatus from 'http-status'
import { apple } from 'worker-auth-providers'
import { Environment } from '../../../../bindings'
import { authProviders } from '../../../config/authProviders'
import { Config, getConfig } from '../../../config/config'
import { AppleUser } from '../../../models/oauth/apple-user.model'
import * as authService from '../../../services/auth.service'
import { getIdTokenFromCode } from '../../../services/oauth/apple.service'
import * as tokenService from '../../../services/token.service'
import { ApiError } from '../../../utils/api-error'
import * as authValidation from '../../../validations/auth.validation'
import { deleteOauthLink, getRedirectUrl, parseState } from './oauth.controller'
type AppleJWT = {
iss: string
aud: string
exp: number
iat: number
sub: string
at_hash: string
email: string
email_verified: string
is_private_email: string
auth_time: number
nonce_supported: boolean
}
const getAppleUser = async (code: string | null, config: Config) => {
if (!code) {
throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request')
}
const appleClientSecret = await apple.convertPrivateKeyToClientSecret({
privateKey: config.oauth.provider.apple.privateKey,
keyIdentifier: config.oauth.provider.apple.keyId,
teamId: config.oauth.provider.apple.teamId,
clientId: config.oauth.provider.apple.clientId,
expAfter: config.oauth.provider.apple.jwtAccessExpirationMinutes * 60
})
const idToken = await getIdTokenFromCode(
code,
config.oauth.provider.apple.clientId,
appleClientSecret,
config.oauth.provider.apple.redirectUrl
)
const userData = decode(idToken).payload as AppleJWT
if (!userData.email) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized')
}
const appleUser = new AppleUser(userData)
return appleUser
}
export const appleRedirect: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const { state } = authValidation.oauthRedirect.parse(c.req.query())
parseState(state)
const location = await apple.redirect({
options: {
clientId: config.oauth.provider.apple.clientId,
redirectTo: config.oauth.provider.apple.redirectUrl,
scope: ['email'],
responseMode: 'form_post',
state: state
}
})
return c.redirect(location, httpStatus.FOUND)
}
export const appleCallback: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const formData = await c.req.formData()
const state = formData.get('state')
if (!state) {
const redirect = new URL('?error=Something went wrong', config.oauth.platform.web.redirectUrl)
.href
return c.redirect(redirect, httpStatus.FOUND)
}
// Set a base redirect url to web in case of no platform info being passed
let redirectBase = config.oauth.platform.web.redirectUrl
try {
redirectBase = getRedirectUrl(state, config)
const appleUser = await getAppleUser(formData.get('code'), config)
const user = await authService.loginOrCreateUserWithOauth(appleUser, config.database)
const tokens = await tokenService.generateAuthTokens(user, config.jwt)
const oneTimeCode = await tokenService.createOneTimeOauthCode(user.id, tokens, config)
const redirect = new URL(`?oneTimeCode=${oneTimeCode}&state=${state}`, redirectBase).href
return c.redirect(redirect, httpStatus.FOUND)
} catch (error) {
const message = error instanceof ApiError ? error.message : 'Something went wrong'
const redirect = new URL(`?error=${message}&state=${state}`, redirectBase).href
return c.redirect(redirect, httpStatus.FOUND)
}
}
export const linkApple: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const payload = c.get('payload')
const userId = payload.sub
if (!userId) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const bodyParse = await c.req.json()
const { code } = authValidation.linkApple.parse(bodyParse)
const appleUser = await getAppleUser(code, config)
await authService.linkUserWithOauth(userId, appleUser, config.database)
c.status(httpStatus.NO_CONTENT as StatusCode)
return c.body(null)
}
export const deleteAppleLink: Handler<Environment> = async (c) => {
return deleteOauthLink(c, authProviders.APPLE)
}
================================================
FILE: src/controllers/auth/oauth/discord.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { discord } from 'worker-auth-providers'
import { Environment } from '../../../../bindings'
import { authProviders } from '../../../config/authProviders'
import { getConfig } from '../../../config/config'
import { DiscordUserType } from '../../../types/oauth.types'
import * as authValidation from '../../../validations/auth.validation'
import {
oauthCallback,
oauthLink,
deleteOauthLink,
validateCallbackBody,
getRedirectUrl
} from './oauth.controller'
export const discordRedirect: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const { state } = authValidation.oauthRedirect.parse(c.req.query())
const redirectUrl = getRedirectUrl(state, config)
const location = await discord.redirect({
options: {
clientId: config.oauth.provider.discord.clientId,
redirectUrl: redirectUrl,
state: state,
scope: 'identify email'
}
})
return c.redirect(location, httpStatus.FOUND)
}
export const discordCallback: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const request = await validateCallbackBody(c, code)
const oauthRequest = discord.users({
options: {
clientId: config.oauth.provider.discord.clientId,
clientSecret: config.oauth.provider.discord.clientSecret,
redirectUrl: redirectUrl
},
request
}) as Promise<{ user: DiscordUserType; tokens: unknown }>
return oauthCallback<typeof authProviders.DISCORD>(c, oauthRequest, authProviders.DISCORD)
}
export const linkDiscord: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const request = await validateCallbackBody(c, code)
const oauthRequest = discord.users({
options: {
clientId: config.oauth.provider.discord.clientId,
clientSecret: config.oauth.provider.discord.clientSecret,
redirectUrl: redirectUrl
},
request
}) as Promise<{ user: DiscordUserType; tokens: unknown }>
return oauthLink<typeof authProviders.DISCORD>(c, oauthRequest, authProviders.DISCORD)
}
export const deleteDiscordLink: Handler<Environment> = async (c) => {
return deleteOauthLink(c, authProviders.DISCORD)
}
================================================
FILE: src/controllers/auth/oauth/facebook.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { facebook } from 'worker-auth-providers'
import { Environment } from '../../../../bindings'
import { authProviders } from '../../../config/authProviders'
import { getConfig } from '../../../config/config'
import * as facebookService from '../../../services/oauth/facebook.service'
import * as authValidation from '../../../validations/auth.validation'
import {
oauthCallback,
oauthLink,
deleteOauthLink,
validateCallbackBody,
getRedirectUrl
} from './oauth.controller'
export const facebookRedirect: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const { state } = authValidation.oauthRedirect.parse(c.req.query())
const redirectUrl = getRedirectUrl(state, config)
const location = await facebookService.redirect({
clientId: config.oauth.provider.facebook.clientId,
redirectUrl: redirectUrl,
state: state
})
return c.redirect(location, httpStatus.FOUND)
}
export const facebookCallback: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const request = await validateCallbackBody(c, code)
const oauthRequest = facebook.users({
options: {
clientId: config.oauth.provider.facebook.clientId,
clientSecret: config.oauth.provider.facebook.clientSecret,
redirectUrl: redirectUrl
},
request
})
return oauthCallback<typeof authProviders.FACEBOOK>(c, oauthRequest, authProviders.FACEBOOK)
}
export const linkFacebook: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = facebook.users({
options: {
clientId: config.oauth.provider.facebook.clientId,
clientSecret: config.oauth.provider.facebook.clientSecret,
redirectUrl: redirectUrl
},
request
})
return oauthLink<typeof authProviders.FACEBOOK>(c, oauthRequest, authProviders.FACEBOOK)
}
export const deleteFacebookLink: Handler<Environment> = async (c) => {
return deleteOauthLink(c, authProviders.FACEBOOK)
}
================================================
FILE: src/controllers/auth/oauth/github.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { github } from 'worker-auth-providers'
import { Environment } from '../../../../bindings'
import { authProviders } from '../../../config/authProviders'
import { getConfig } from '../../../config/config'
import * as githubService from '../../../services/oauth/github.service'
import * as authValidation from '../../../validations/auth.validation'
import {
oauthCallback,
oauthLink,
deleteOauthLink,
validateCallbackBody,
getRedirectUrl
} from './oauth.controller'
export const githubRedirect: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const { state } = authValidation.oauthRedirect.parse(c.req.query())
const redirectUrl = getRedirectUrl(state, config)
const location = await githubService.redirect({
clientId: config.oauth.provider.github.clientId,
redirectTo: redirectUrl,
state: state
})
return c.redirect(location, httpStatus.FOUND)
}
export const githubCallback: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = github.users({
options: {
clientId: config.oauth.provider.github.clientId,
clientSecret: config.oauth.provider.github.clientSecret,
redirectUrl: redirectUrl
},
request
})
return oauthCallback<typeof authProviders.GITHUB>(c, oauthRequest, authProviders.GITHUB)
}
export const linkGithub: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = github.users({
options: {
clientId: config.oauth.provider.github.clientId,
clientSecret: config.oauth.provider.github.clientSecret,
redirectUrl: redirectUrl
},
request
})
return oauthLink(c, oauthRequest, authProviders.GITHUB)
}
export const deleteGithubLink: Handler<Environment> = async (c) => {
return deleteOauthLink(c, authProviders.GITHUB)
}
================================================
FILE: src/controllers/auth/oauth/google.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { google } from 'worker-auth-providers'
import { Environment } from '../../../../bindings'
import { authProviders } from '../../../config/authProviders'
import { getConfig } from '../../../config/config'
import { GoogleUserType } from '../../../types/oauth.types'
import * as authValidation from '../../../validations/auth.validation'
import {
oauthCallback,
oauthLink,
deleteOauthLink,
validateCallbackBody,
getRedirectUrl
} from './oauth.controller'
export const googleRedirect: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const { state } = authValidation.oauthRedirect.parse(c.req.query())
const redirectUrl = getRedirectUrl(state, config)
const location = await google.redirect({
options: {
clientId: config.oauth.provider.google.clientId,
redirectUrl: redirectUrl,
state: state
}
})
return c.redirect(location, httpStatus.FOUND)
}
export const googleCallback: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = google.users({
options: {
clientId: config.oauth.provider.google.clientId,
clientSecret: config.oauth.provider.google.clientSecret,
redirectUrl: redirectUrl
},
request
}) as Promise<{ user: GoogleUserType; tokens: unknown }>
return oauthCallback<typeof authProviders.GOOGLE>(c, oauthRequest, authProviders.GOOGLE)
}
export const linkGoogle: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = google.users({
options: {
clientId: config.oauth.provider.google.clientId,
clientSecret: config.oauth.provider.google.clientSecret,
redirectUrl: redirectUrl
},
request
}) as Promise<{ user: GoogleUserType; tokens: unknown }>
return oauthLink<typeof authProviders.GOOGLE>(c, oauthRequest, authProviders.GOOGLE)
}
export const deleteGoogleLink: Handler<Environment> = async (c) => {
return deleteOauthLink(c, authProviders.GOOGLE)
}
================================================
FILE: src/controllers/auth/oauth/oauth.controller.ts
================================================
import { Context, Handler } from 'hono'
import type { StatusCode } from 'hono/utils/http-status'
import httpStatus from 'http-status'
import { Environment } from '../../../../bindings'
import { Config, getConfig } from '../../../config/config'
import { providerUserFactory } from '../../../factories/oauth.factory'
import { OAuthUserModel } from '../../../models/oauth/oauth-base.model'
import * as authService from '../../../services/auth.service'
import * as tokenService from '../../../services/token.service'
import * as userService from '../../../services/user.service'
import { AuthProviderType, OauthUserTypes } from '../../../types/oauth.types'
import { ApiError } from '../../../utils/api-error'
import * as authValidation from '../../../validations/auth.validation'
type State = {
platform: 'web' | 'android' | 'ios'
}
export const parseState = (state: string) => {
try {
const decodedState = JSON.parse(atob(state)) as State
authValidation.stateValidation.parse(decodedState)
return decodedState
} catch {
throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request')
}
}
export const getRedirectUrl = (state: string, config: Config) => {
try {
const decodedState = parseState(state)
const platform = decodedState.platform
return config.oauth.platform[platform].redirectUrl
} catch {
throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request')
}
}
export const oauthCallback = async <T extends AuthProviderType>(
c: Context<Environment>,
oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>,
providerType: T
): Promise<Response> => {
const config = getConfig(c.env)
let providerUser: OAuthUserModel
try {
const result = await oauthRequest
const UserModel = providerUserFactory[providerType]
providerUser = new UserModel(result.user)
} catch {
throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized')
}
const user = await authService.loginOrCreateUserWithOauth(providerUser, config.database)
const tokens = await tokenService.generateAuthTokens(user, config.jwt)
return c.json({ user, tokens }, httpStatus.OK as StatusCode)
}
export const oauthLink = async <T extends AuthProviderType>(
c: Context<Environment>,
oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>,
providerType: T
): Promise<Response> => {
const payload = c.get('payload')
const userId = payload.sub
if (!userId) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const config = getConfig(c.env)
let providerUser: OAuthUserModel
try {
const result = await oauthRequest
const UserModel = providerUserFactory[providerType]
providerUser = new UserModel(result.user)
} catch {
throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized')
}
await authService.linkUserWithOauth(userId, providerUser, config.database)
c.status(httpStatus.NO_CONTENT as StatusCode)
return c.body(null)
}
export const deleteOauthLink = async (
c: Context<Environment>,
provider: AuthProviderType
): Promise<Response> => {
const payload = c.get('payload')
const userId = payload.sub
if (!userId) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const config = getConfig(c.env)
await authService.deleteOauthLink(userId, provider, config.database)
c.status(httpStatus.NO_CONTENT as StatusCode)
return c.body(null)
}
export const validateCallbackBody = async (
c: Context<Environment>,
code: string
): Promise<Request> => {
const url = new URL(c.req.url)
url.searchParams.set('code', code)
const request = new Request(url.toString())
return request
}
export const validateOauthOneTimeCode: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { code } = authValidation.validateOneTimeCode.parse(bodyParse)
const oauthCode = await tokenService.getOneTimeOauthCode(code, config)
const user = await userService.getUserById(oauthCode.user_id, config.database)
const tokenResponse = {
access: {
token: oauthCode.access_token,
expires: oauthCode.access_token_expires_at
},
refresh: {
token: oauthCode.refresh_token,
expires: oauthCode.refresh_token_expires_at
}
}
return c.json({ user, tokens: tokenResponse }, httpStatus.OK as StatusCode)
}
================================================
FILE: src/controllers/auth/oauth/spotify.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { spotify } from 'worker-auth-providers'
import { Environment } from '../../../../bindings'
import { authProviders } from '../../../config/authProviders'
import { getConfig } from '../../../config/config'
import * as spotifyService from '../../../services/oauth/spotify.service'
import { SpotifyUserType } from '../../../types/oauth.types'
import * as authValidation from '../../../validations/auth.validation'
import {
oauthCallback,
oauthLink,
deleteOauthLink,
validateCallbackBody,
getRedirectUrl
} from './oauth.controller'
export const spotifyRedirect: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const { state } = authValidation.oauthRedirect.parse(c.req.query())
const redirectUrl = getRedirectUrl(state, config)
const location = await spotifyService.redirect({
clientId: config.oauth.provider.spotify.clientId,
redirectUrl: redirectUrl,
state: state,
scope: 'user-read-email'
})
return c.redirect(location, httpStatus.FOUND)
}
export const spotifyCallback: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = spotify.users({
options: {
clientId: config.oauth.provider.spotify.clientId,
clientSecret: config.oauth.provider.spotify.clientSecret,
redirectUrl: redirectUrl
},
request
}) as Promise<{ user: SpotifyUserType; tokens: unknown }>
return oauthCallback<typeof authProviders.SPOTIFY>(c, oauthRequest, authProviders.SPOTIFY)
}
export const linkSpotify: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const { platform, code } = authValidation.oauthCallback.parse(bodyParse)
const request = await validateCallbackBody(c, code)
const redirectUrl = config.oauth.platform[platform].redirectUrl
const oauthRequest = spotify.users({
options: {
clientId: config.oauth.provider.spotify.clientId,
clientSecret: config.oauth.provider.spotify.clientSecret,
redirectUrl: redirectUrl
},
request
}) as Promise<{ user: SpotifyUserType; tokens: unknown }>
return oauthLink<typeof authProviders.SPOTIFY>(c, oauthRequest, authProviders.SPOTIFY)
}
export const deleteSpotifyLink: Handler<Environment> = async (c) => {
return deleteOauthLink(c, authProviders.SPOTIFY)
}
================================================
FILE: src/controllers/user.controller.ts
================================================
import { Handler } from 'hono'
import httpStatus from 'http-status'
import { Environment } from '../../bindings'
import { getConfig } from '../config/config'
import * as userService from '../services/user.service'
import { ApiError } from '../utils/api-error'
import * as userValidation from '../validations/user.validation'
export const createUser: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const bodyParse = await c.req.json()
const body = await userValidation.createUser.parseAsync(bodyParse)
const user = await userService.createUser(body, config.database)
return c.json(user, httpStatus.CREATED)
}
export const getUsers: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const queryParse = c.req.query()
const query = userValidation.getUsers.parse(queryParse)
const filter = { email: query.email }
const options = { sortBy: query.sort_by, limit: query.limit, page: query.page }
const result = await userService.queryUsers(filter, options, config.database)
return c.json(result, httpStatus.OK)
}
export const getUser: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const paramsParse = c.req.param()
const params = userValidation.getUser.parse(paramsParse)
const user = await userService.getUserById(params.userId, config.database)
if (!user) {
throw new ApiError(httpStatus.NOT_FOUND, 'User not found')
}
return c.json(user, httpStatus.OK)
}
export const updateUser: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const paramsParse = c.req.param()
const bodyParse = await c.req.json()
const { params, body } = userValidation.updateUser.parse({ params: paramsParse, body: bodyParse })
const user = await userService.updateUserById(params.userId, body, config.database)
return c.json(user, httpStatus.OK)
}
export const deleteUser: Handler<Environment> = async (c) => {
const config = getConfig(c.env)
const paramsParse = c.req.param()
const params = userValidation.deleteUser.parse(paramsParse)
await userService.deleteUserById(params.userId, config.database)
c.status(httpStatus.NO_CONTENT)
return c.body(null)
}
================================================
FILE: src/durable-objects/rate-limiter.do.ts
================================================
import dayjs from 'dayjs'
import { Context, Hono } from 'hono'
import httpStatus from 'http-status'
import { z, ZodError } from 'zod'
import { fromError } from 'zod-validation-error'
import { Environment } from '../../bindings'
interface Config {
scope: string
key: string
limit: number
interval: number
}
const configValidation = z.object({
scope: z.string(),
key: z.string(),
limit: z.number().int().positive(),
interval: z.number().int().positive()
})
export class RateLimiter {
state: DurableObjectState
env: Environment['Bindings']
app: Hono = new Hono()
constructor(state: DurableObjectState, env: Environment['Bindings']) {
this.state = state
this.env = env
this.app.post('/', async (c) => {
await this.setAlarm()
let config
try {
config = await this.getConfig(c)
} catch (err: unknown) {
let errorMessage
if (err instanceof ZodError) {
errorMessage = fromError(err)
}
return c.json(
{
statusCode: httpStatus.BAD_REQUEST,
error: errorMessage
},
httpStatus.BAD_REQUEST
)
}
const rate = await this.calculateRate(config)
const blocked = this.isRateLimited(rate, config.limit)
const headers = this.getHeaders(blocked, config)
const remaining = blocked ? 0 : Math.floor(config.limit - rate - 1)
// If the remaining requests is negative set it to 0 to indicate 100% throughput
const remainingHeader = remaining >= 0 ? remaining : 0
return c.json(
{
blocked,
remaining: remainingHeader,
expires: headers.expires
},
httpStatus.OK,
headers
)
})
}
async alarm() {
const values = await this.state.storage.list()
for await (const [key, _value] of values) {
const [_scope, _key, _limit, interval, timestamp] = key.split('|')
const currentWindow = Math.floor(this.nowUnix() / parseInt(interval))
const timestampLessThan = currentWindow - 2 // expire all keys after 2 intervals have passed
if (parseInt(timestamp) < timestampLessThan) {
await this.state.storage.delete(key)
}
}
}
async setAlarm() {
const alarm = await this.state.storage.getAlarm()
if (!alarm) {
this.state.storage.setAlarm(dayjs().add(6, 'hours').toDate())
}
}
async getConfig(c: Context) {
const body = await c.req.json<Config>()
const config = configValidation.parse(body)
return config
}
async incrementRequestCount(key: string) {
const currentRequestCount = await this.getRequestCount(key)
await this.state.storage.put(key, currentRequestCount + 1)
}
async getRequestCount(key: string): Promise<number> {
return parseInt((await this.state.storage.get(key)) as string) || 0
}
nowUnix() {
return dayjs().unix()
}
async calculateRate(config: Config) {
const keyPrefix = `${config.scope}|${config.key}|${config.limit}|${config.interval}`
const currentWindow = Math.floor(this.nowUnix() / config.interval)
const distanceFromLastWindow = this.nowUnix() % config.interval
const currentKey = `${keyPrefix}|${currentWindow}`
const previousKey = `${keyPrefix}|${currentWindow - 1}`
const currentCount = await this.getRequestCount(currentKey)
const previousCount = (await this.getRequestCount(previousKey)) || 0
const rate =
(previousCount * (config.interval - distanceFromLastWindow)) / config.interval + currentCount
if (!this.isRateLimited(rate, config.limit)) {
await this.incrementRequestCount(currentKey)
}
return rate
}
isRateLimited(rate: number, limit: number) {
return rate >= limit
}
getHeaders(blocked: boolean, config: Config) {
const expires = this.expirySeconds(config)
const retryAfter = this.retryAfter(expires)
const headers: { expires: string; 'cache-control'?: string } = {
expires: retryAfter.toString()
}
if (!blocked) {
return headers
}
headers['cache-control'] = `public, max-age=${expires}, s-maxage=${expires}, must-revalidate`
return headers
}
expirySeconds(config: Config) {
const currentWindowStart = Math.floor(this.nowUnix() / config.interval)
const currentWindowEnd = currentWindowStart + 1
const secondsRemaining = currentWindowEnd * config.interval - this.nowUnix()
return secondsRemaining
}
retryAfter(expires: number) {
return dayjs().add(expires, 'seconds').toString()
}
async fetch(request: Request): Promise<Response> {
return this.app.fetch(request)
}
}
================================================
FILE: src/factories/oauth.factory.ts
================================================
import { AppleUser } from '../models/oauth/apple-user.model'
import { DiscordUser } from '../models/oauth/discord-user.model'
import { FacebookUser } from '../models/oauth/facebook-user.model'
import { GithubUser } from '../models/oauth/github-user.model'
import { GoogleUser } from '../models/oauth/google-user.model'
import { SpotifyUser } from '../models/oauth/spotify-user.model'
import { ProviderUserMapping } from '../types/oauth.types'
export const providerUserFactory: ProviderUserMapping = {
facebook: FacebookUser,
discord: DiscordUser,
google: GoogleUser,
spotify: SpotifyUser,
apple: AppleUser,
github: GithubUser
}
================================================
FILE: src/index.ts
================================================
import { sentry } from '@hono/sentry'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import httpStatus from 'http-status'
import { Environment } from '../bindings'
import { errorHandler } from './middlewares/error'
import { defaultRoutes } from './routes'
import { ApiError } from './utils/api-error'
export { RateLimiter } from './durable-objects/rate-limiter.do'
const app = new Hono<Environment>()
app.use('*', sentry())
app.use('*', cors())
app.notFound(() => {
throw new ApiError(httpStatus.NOT_FOUND, 'Not found')
})
app.onError(errorHandler)
defaultRoutes.forEach((route) => {
app.route(`${route.path}`, route.route)
})
export default app
================================================
FILE: src/middlewares/auth.ts
================================================
import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt'
import { MiddlewareHandler } from 'hono'
import httpStatus from 'http-status'
import { Environment } from '../../bindings'
import { getConfig } from '../config/config'
import { roleRights, Permission, Role } from '../config/roles'
import { tokenTypes } from '../config/tokens'
import { getUserById } from '../services/user.service'
import { ApiError } from '../utils/api-error'
const authenticate = async (jwtToken: string, secret: string) => {
let authorized = false
let payload
try {
authorized = await jwt.verify(jwtToken, secret)
const decoded = jwt.decode(jwtToken)
payload = decoded.payload as JwtPayload
authorized = authorized && payload.type === tokenTypes.ACCESS
} catch {}
return { authorized, payload }
}
export const auth =
(...requiredRights: Permission[]): MiddlewareHandler<Environment> =>
async (c, next) => {
const credentials = c.req.raw.headers.get('Authorization')
const config = getConfig(c.env)
if (!credentials) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const parts = credentials.split(/\s+/)
if (parts.length !== 2) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const jwtToken = parts[1]
const { authorized, payload } = await authenticate(jwtToken, config.jwt.secret)
if (!authorized || !payload || !payload.sub) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
if (requiredRights.length) {
const userRights = roleRights[payload.role as Role]
const hasRequiredRights = requiredRights.every((requiredRight) =>
(userRights as unknown as string[]).includes(requiredRight)
)
if (!hasRequiredRights && c.req.param('userId') !== payload.sub) {
throw new ApiError(httpStatus.FORBIDDEN, 'Forbidden')
}
}
if (!payload.isEmailVerified) {
const user = await getUserById(payload.sub, config['database'])
if (!user) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const url = new URL(c.req.url)
if (url.pathname !== '/v1/auth/send-verification-email') {
throw new ApiError(httpStatus.FORBIDDEN, 'Please verify your email')
}
}
c.set('payload', payload)
await next()
}
================================================
FILE: src/middlewares/error.ts
================================================
import { getSentry } from '@hono/sentry'
import type { ErrorHandler } from 'hono'
import { StatusCode } from 'hono/utils/http-status'
import httpStatus from 'http-status'
import type { Toucan } from 'toucan-js'
import { ZodError } from 'zod'
import { Environment } from '../../bindings'
import { ApiError } from '../utils/api-error'
import { generateZodErrorMessage } from '../utils/zod'
const genericJSONErrMsg = 'Unexpected end of JSON input'
export const errorConverter = (err: unknown, sentry: Toucan): ApiError => {
let error = err
if (error instanceof ZodError) {
const errorMessage = generateZodErrorMessage(error)
error = new ApiError(httpStatus.BAD_REQUEST, errorMessage)
} else if (error instanceof SyntaxError && error.message.includes(genericJSONErrMsg)) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid JSON payload')
} else if (!(error instanceof ApiError)) {
const castedErr = (typeof error === 'object' ? error : {}) as Record<string, unknown>
const statusCode: StatusCode =
typeof castedErr.statusCode === 'number'
? (castedErr.statusCode as StatusCode)
: httpStatus.INTERNAL_SERVER_ERROR
const message = (castedErr.description ||
castedErr.message ||
httpStatus[statusCode.toString() as keyof typeof httpStatus]) as string
if (statusCode >= httpStatus.INTERNAL_SERVER_ERROR) {
// Log any unhandled application error
sentry.captureException(error)
}
error = new ApiError(statusCode, message, false)
}
return error as ApiError
}
export const errorHandler: ErrorHandler<Environment> = async (err, c) => {
// Can't load config in case error is inside config so load env here and default
// to highest obscurity aka production if env is not set
const env = c.env.ENV || 'production'
const sentry = getSentry(c)
const error = errorConverter(err, sentry)
if (env === 'production' && !error.isOperational) {
error.statusCode = httpStatus.INTERNAL_SERVER_ERROR
error.message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR].toString()
}
const response = {
code: error.statusCode,
message: error.message,
...(env === 'development' && { stack: err.stack })
}
delete c.error // Don't pass to sentry middleware as it is either logged or already handled
return c.json(response, error.statusCode as StatusCode)
}
================================================
FILE: src/middlewares/rate-limiter.ts
================================================
import dayjs from 'dayjs'
import { Context, MiddlewareHandler } from 'hono'
import httpStatus from 'http-status'
import { Environment } from '../../bindings'
import { ApiError } from '../utils/api-error'
const fakeDomain = 'http://rate-limiter.com/'
const getRateLimitKey = (c: Context) => {
const ip = c.req.raw.headers.get('cf-connecting-ip')
const user = c.get('payload')?.sub
const uniqueKey = user ? user : ip
return uniqueKey
}
const getCacheKey = (endpoint: string, key: number | string, limit: number, interval: number) => {
return `${fakeDomain}${endpoint}/${key}/${limit}/${interval}`
}
const setRateLimitHeaders = (
c: Context,
secondsExpires: number,
limit: number,
remaining: number,
interval: number
) => {
c.header('X-RateLimit-Limit', limit.toString())
c.header('X-RateLimit-Remaining', remaining.toString())
c.header('X-RateLimit-Reset', secondsExpires.toString())
c.header('X-RateLimit-Policy', `${limit};w=${interval};comment="Sliding window"`)
}
export const rateLimit = (interval: number, limit: number): MiddlewareHandler<Environment> => {
return async (c, next) => {
const key = getRateLimitKey(c)
const endpoint = new URL(c.req.url).pathname
const id = c.env.RATE_LIMITER.idFromName(key)
const rateLimiter = c.env.RATE_LIMITER.get(id)
const cache = await caches.open('rate-limiter')
const cacheKey = getCacheKey(endpoint, key, limit, interval)
const cached = await cache.match(cacheKey)
let res: Response
if (!cached) {
res = await rateLimiter.fetch(
new Request(fakeDomain, {
method: 'POST',
body: JSON.stringify({
scope: endpoint,
key,
limit,
interval
})
})
)
} else {
res = cached
}
const clonedRes = res.clone()
// eslint-disable-next-line no-console
console.log() // This randomly fixes isolated storage errors
const body = await clonedRes.json<{ blocked: boolean; remaining: number; expires: string }>()
const secondsExpires = dayjs(body.expires).unix() - dayjs().unix()
setRateLimitHeaders(c, secondsExpires, limit, body.remaining, interval)
if (body.blocked) {
if (!cached) {
// Only cache blocked responses
c.executionCtx.waitUntil(cache.put(cacheKey, res))
}
throw new ApiError(httpStatus.TOO_MANY_REQUESTS, 'Too many requests')
}
await next()
}
}
================================================
FILE: src/models/base.model.ts
================================================
export abstract class BaseModel {
abstract private_fields: string[]
toJSON() {
const properties = Object.getOwnPropertyNames(this)
const publicProperties = properties.filter((property) => {
return !this.private_fields.includes(property) && property !== 'private_fields'
})
const json = publicProperties.reduce((obj: Record<string, unknown>, key: string) => {
obj[key] = this[key as keyof typeof this]
return obj
}, {})
return json
}
}
================================================
FILE: src/models/oauth/apple-user.model.ts
================================================
import { authProviders } from '../../config/authProviders'
import { AppleUserType } from '../../types/oauth.types'
import { OAuthUserModel } from './oauth-base.model'
export class AppleUser extends OAuthUserModel {
constructor(user: AppleUserType) {
if (!user.email) {
throw new Error('Apple account must have an email linked')
}
super({
_id: user.sub,
providerType: authProviders.APPLE,
_name: user.name,
_email: user.email
})
}
}
================================================
FILE: src/models/oauth/discord-user.model.ts
================================================
import { authProviders } from '../../config/authProviders'
import { DiscordUserType } from '../../types/oauth.types'
import { OAuthUserModel } from './oauth-base.model'
export class DiscordUser extends OAuthUserModel {
constructor(user: DiscordUserType) {
super({
providerType: authProviders.DISCORD,
_name: user.username,
_id: user.id,
_email: user.email
})
}
}
================================================
FILE: src/models/oauth/facebook-user.model.ts
================================================
import { authProviders } from '../../config/authProviders'
import { FacebookUserType } from '../../types/oauth.types'
import { OAuthUserModel } from './oauth-base.model'
export class FacebookUser extends OAuthUserModel {
constructor(user: FacebookUserType) {
super({
providerType: authProviders.FACEBOOK,
_name: `${user.first_name} ${user.last_name}`,
_id: user.id,
_email: user.email
})
}
}
================================================
FILE: src/models/oauth/github-user.model.ts
================================================
import { authProviders } from '../../config/authProviders'
import { GithubUserType } from '../../types/oauth.types'
import { OAuthUserModel } from './oauth-base.model'
export class GithubUser extends OAuthUserModel {
constructor(user: GithubUserType) {
super({
_id: user.id.toString(),
providerType: authProviders.GITHUB,
_name: user.name,
_email: user.email
})
}
}
================================================
FILE: src/models/oauth/google-user.model.ts
================================================
import { authProviders } from '../../config/authProviders'
import { GoogleUserType } from '../../types/oauth.types'
import { OAuthUserModel } from './oauth-base.model'
export class GoogleUser extends OAuthUserModel {
constructor(user: GoogleUserType) {
super({
providerType: authProviders.GOOGLE,
_name: user.name,
_id: user.id,
_email: user.email
})
}
}
================================================
FILE: src/models/oauth/oauth-base.model.ts
================================================
import { AuthProviderType, OAuthUserType } from '../../types/oauth.types'
import { BaseModel } from '../base.model'
export class OAuthUserModel extends BaseModel implements OAuthUserType {
_id: string
_email: string
_name?: string
providerType: AuthProviderType
private_fields = []
constructor(user: OAuthUserType) {
super()
this._id = `${user._id}`
this._email = user._email
this._name = user._name || undefined
this.providerType = user.providerType
}
}
================================================
FILE: src/models/oauth/spotify-user.model.ts
================================================
import { authProviders } from '../../config/authProviders'
import { SpotifyUserType } from '../../types/oauth.types'
import { OAuthUserModel } from './oauth-base.model'
export class SpotifyUser extends OAuthUserModel {
constructor(user: SpotifyUserType) {
super({
providerType: authProviders.SPOTIFY,
_name: user.display_name,
_id: user.id,
_email: user.email
})
}
}
================================================
FILE: src/models/one-time-oauth-code.ts
================================================
import { Selectable } from 'kysely'
import { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table'
import { BaseModel } from './base.model'
export class OneTimeOauthCode extends BaseModel implements Selectable<OneTimeOauthCodeTable> {
code: string
user_id: string
access_token: string
access_token_expires_at: Date
refresh_token: string
refresh_token_expires_at: Date
expires_at: Date
created_at: Date
updated_at: Date
private_fields = ['created_at', 'updated_at']
constructor(oneTimeCode: Selectable<OneTimeOauthCodeTable>) {
super()
this.code = oneTimeCode.code
this.user_id = oneTimeCode.user_id
this.access_token = oneTimeCode.access_token
this.access_token_expires_at = oneTimeCode.access_token_expires_at
this.refresh_token = oneTimeCode.refresh_token
this.refresh_token_expires_at = oneTimeCode.refresh_token_expires_at
this.expires_at = oneTimeCode.expires_at
this.created_at = oneTimeCode.created_at
this.updated_at = oneTimeCode.updated_at
}
}
================================================
FILE: src/models/token.model.ts
================================================
export interface TokenResponse {
access: {
token: string
expires: Date
}
refresh: {
token: string
expires: Date
}
}
================================================
FILE: src/models/user.model.ts
================================================
import bcrypt from 'bcryptjs'
import { Selectable } from 'kysely'
import { Role } from '../config/roles'
import { UserTable } from '../tables/user.table'
import { BaseModel } from './base.model'
export class User extends BaseModel implements Selectable<UserTable> {
id: string
name: string | null
email: string
is_email_verified: boolean
role: Role
password: string | null
private_fields = ['password', 'created_at', 'updated_at']
constructor(user: Selectable<UserTable>) {
super()
this.role = user.role
this.id = user.id
this.name = user.name || null
this.email = user.email
this.is_email_verified = user.is_email_verified
this.role = user.role
this.password = user.password
}
isPasswordMatch = async (userPassword: string): Promise<boolean> => {
if (!this.password) throw 'No password connected to user'
return await bcrypt.compare(userPassword, this.password)
}
}
================================================
FILE: src/routes/auth.route.ts
================================================
import { Hono } from 'hono'
import { Environment } from '../../bindings'
import * as authController from '../controllers/auth/auth.controller'
import * as appleController from '../controllers/auth/oauth/apple.controller'
import * as discordController from '../controllers/auth/oauth/discord.controller'
import * as facebookController from '../controllers/auth/oauth/facebook.controller'
import * as githubController from '../controllers/auth/oauth/github.controller'
import * as googleController from '../controllers/auth/oauth/google.controller'
import * as oauthController from '../controllers/auth/oauth/oauth.controller'
import * as spotifyController from '../controllers/auth/oauth/spotify.controller'
import { auth } from '../middlewares/auth'
import { rateLimit } from '../middlewares/rate-limiter'
export const route = new Hono<Environment>()
const twoMinutes = 120
const oneRequest = 1
route.post('/register', authController.register)
route.post('/login', authController.login)
route.post('/refresh-tokens', authController.refreshTokens)
route.post('/forgot-password', authController.forgotPassword)
route.post('/reset-password', authController.resetPassword)
route.post(
'/send-verification-email',
auth(),
rateLimit(twoMinutes, oneRequest),
authController.sendVerificationEmail
)
route.post('/verify-email', authController.verifyEmail)
route.get('/authorisations', auth(), authController.getAuthorisations)
route.get('/github/redirect', githubController.githubRedirect)
route.get('/google/redirect', googleController.googleRedirect)
route.get('/spotify/redirect', spotifyController.spotifyRedirect)
route.get('/discord/redirect', discordController.discordRedirect)
route.get('/facebook/redirect', facebookController.facebookRedirect)
route.get('/apple/redirect', appleController.appleRedirect)
route.post('/github/callback', githubController.githubCallback)
route.post('/spotify/callback', spotifyController.spotifyCallback)
route.post('/discord/callback', discordController.discordCallback)
route.post('/google/callback', googleController.googleCallback)
route.post('/facebook/callback', facebookController.facebookCallback)
route.post('/apple/callback', appleController.appleCallback)
route.post('/github/:userId', auth('manageUsers'), githubController.linkGithub)
route.post('/spotify/:userId', auth('manageUsers'), spotifyController.linkSpotify)
route.post('/discord/:userId', auth('manageUsers'), discordController.linkDiscord)
route.post('/google/:userId', auth('manageUsers'), googleController.linkGoogle)
route.post('/facebook/:userId', auth('manageUsers'), facebookController.linkFacebook)
route.post('/apple/:userId', auth('manageUsers'), appleController.linkApple)
route.delete('/github/:userId', auth('manageUsers'), githubController.deleteGithubLink)
route.delete('/spotify/:userId', auth('manageUsers'), spotifyController.deleteSpotifyLink)
route.delete('/discord/:userId', auth('manageUsers'), discordController.deleteDiscordLink)
route.delete('/google/:userId', auth('manageUsers'), googleController.deleteGoogleLink)
route.delete('/facebook/:userId', auth('manageUsers'), facebookController.deleteFacebookLink)
route.delete('/apple/:userId', auth('manageUsers'), appleController.deleteAppleLink)
route.post('/validate', oauthController.validateOauthOneTimeCode)
================================================
FILE: src/routes/index.ts
================================================
import { route as authRoute } from './auth.route'
import { route as userRoute } from './user.route'
const base_path = 'v1'
export const defaultRoutes = [
{
path: `/${base_path}/auth`,
route: authRoute
},
{
path: `/${base_path}/users`,
route: userRoute
}
]
================================================
FILE: src/routes/user.route.ts
================================================
import { Hono } from 'hono'
import { Environment } from '../../bindings'
import * as userController from '../controllers/user.controller'
import { auth } from '../middlewares/auth'
export const route = new Hono<Environment>()
route.post('/', auth('manageUsers'), userController.createUser)
route.get('/', auth('getUsers'), userController.getUsers)
route.get('/:userId', auth('getUsers'), userController.getUser)
route.patch('/:userId', auth('manageUsers'), userController.updateUser)
route.delete('/:userId', auth('manageUsers'), userController.deleteUser)
================================================
FILE: src/services/auth.service.ts
================================================
import httpStatus from 'http-status'
import { Config } from '../config/config'
import { getDBClient } from '../config/database'
import { Role } from '../config/roles'
import { tokenTypes } from '../config/tokens'
import { OAuthUserModel } from '../models/oauth/oauth-base.model'
import { TokenResponse } from '../models/token.model'
import { User } from '../models/user.model'
import { AuthProviderType } from '../types/oauth.types'
import { ApiError } from '../utils/api-error'
import { Register } from '../validations/auth.validation'
import * as tokenService from './token.service'
import * as userService from './user.service'
import { createUser } from './user.service'
export const loginUserWithEmailAndPassword = async (
email: string,
password: string,
databaseConfig: Config['database']
): Promise<User> => {
const user = await userService.getUserByEmail(email, databaseConfig)
// If password is null then the user must login with a social account
if (user && !user.password) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please login with your social account')
}
if (!user || !(await user.isPasswordMatch(password))) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password')
}
return user
}
export const refreshAuth = async (refreshToken: string, config: Config): Promise<TokenResponse> => {
try {
const refreshTokenDoc = await tokenService.verifyToken(
refreshToken,
tokenTypes.REFRESH,
config.jwt.secret
)
if (!refreshTokenDoc.sub) {
throw new Error()
}
const user = await userService.getUserById(refreshTokenDoc.sub, config.database)
if (!user) {
throw new Error()
}
return tokenService.generateAuthTokens(user, config.jwt)
} catch {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
}
export const register = async (
body: Register,
databaseConfig: Config['database']
): Promise<User> => {
const registerBody = { ...body, role: 'user' as Role, is_email_verified: false }
const newUser = await createUser(registerBody, databaseConfig)
return newUser
}
export const resetPassword = async (
resetPasswordToken: string,
newPassword: string,
config: Config
): Promise<void> => {
try {
const resetPasswordTokenDoc = await tokenService.verifyToken(
resetPasswordToken,
tokenTypes.RESET_PASSWORD,
config.jwt.secret
)
if (!resetPasswordTokenDoc.sub) {
throw new Error()
}
const userId = resetPasswordTokenDoc.sub
const user = await userService.getUserById(userId, config.database)
if (!user) {
throw new Error()
}
await userService.updateUserById(user.id, { password: newPassword }, config.database)
} catch {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed')
}
}
export const verifyEmail = async (verifyEmailToken: string, config: Config): Promise<void> => {
try {
const verifyEmailTokenDoc = await tokenService.verifyToken(
verifyEmailToken,
tokenTypes.VERIFY_EMAIL,
config.jwt.secret
)
if (!verifyEmailTokenDoc.sub) {
throw new Error()
}
const userId = verifyEmailTokenDoc.sub
const user = await userService.getUserById(userId, config.database)
if (!user) {
throw new Error()
}
await userService.updateUserById(user.id, { is_email_verified: true }, config.database)
} catch {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed')
}
}
export const loginOrCreateUserWithOauth = async (
providerUser: OAuthUserModel,
databaseConfig: Config['database']
): Promise<User> => {
const user = await userService.getUserByProviderIdType(
providerUser._id,
providerUser.providerType,
databaseConfig
)
if (user) return user
return userService.createOauthUser(providerUser, databaseConfig)
}
export const linkUserWithOauth = async (
userId: string,
providerUser: OAuthUserModel,
databaseConfig: Config['database']
): Promise<void> => {
const db = getDBClient(databaseConfig)
await db.transaction().execute(async (trx) => {
try {
await trx
.selectFrom('user')
.selectAll()
.where('user.id', '=', userId)
.executeTakeFirstOrThrow()
} catch {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
await trx
.insertInto('authorisations')
.values({
user_id: userId,
provider_user_id: providerUser._id,
provider_type: providerUser.providerType
})
.executeTakeFirstOrThrow()
})
}
export const deleteOauthLink = async (
userId: string,
provider: AuthProviderType,
databaseConfig: Config['database']
): Promise<void> => {
const db = getDBClient(databaseConfig)
await db.transaction().execute(async (trx) => {
const { count } = trx.fn
let loginsNo: number
try {
const logins = await trx
.selectFrom('user')
.select('password')
.select(count<number>('authorisations.provider_user_id').as('authorisations'))
.leftJoin('authorisations', 'authorisations.user_id', 'user.id')
.where('user.id', '=', userId)
.groupBy('user.password')
.executeTakeFirstOrThrow()
loginsNo = logins.password !== null ? logins.authorisations + 1 : logins.authorisations
} catch {
throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked')
}
const minLoginMethods = 1
if (loginsNo <= minLoginMethods) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot unlink last login method')
}
const result = await trx
.deleteFrom('authorisations')
.where('user_id', '=', userId)
.where('provider_type', '=', provider)
.executeTakeFirst()
if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked')
}
})
}
================================================
FILE: src/services/email.service.ts
================================================
import { SESClient, SendEmailCommand, Message } from '@aws-sdk/client-ses'
import { Config } from '../config/config'
let client: SESClient
export interface EmailData {
name: string
token: string
}
const getClient = (awsConfig: Config['aws']): SESClient => {
client =
client ||
new SESClient({
credentials: {
accessKeyId: awsConfig.accessKeyId,
secretAccessKey: awsConfig.secretAccessKey
},
region: awsConfig.region
})
return client
}
const sendEmail = async (
to: string,
sender: string,
message: Message,
awsConfig: Config['aws']
): Promise<void> => {
const sesClient = getClient(awsConfig)
const command = new SendEmailCommand({
Destination: { ToAddresses: [to] },
Source: sender,
Message: message
})
await sesClient.send(command)
}
export const sendResetPasswordEmail = async (
email: string,
emailData: EmailData,
config: Config
): Promise<void> => {
const message = {
Subject: {
Data: 'Reset your password',
Charset: 'UTF-8'
},
Body: {
Text: {
Charset: 'UTF-8',
Data: `
Hello ${emailData.name}
Please reset your password by clicking the following link:
${emailData.token}
`
}
}
}
await sendEmail(email, config.email.sender, message, config.aws)
}
export const sendVerificationEmail = async (
email: string,
emailData: EmailData,
config: Config
): Promise<void> => {
const message = {
Subject: {
Data: 'Verify your email address',
Charset: 'UTF-8'
},
Body: {
Text: {
Charset: 'UTF-8',
Data: `
Hello ${emailData.name}
Please verify your email by clicking the following link:
${emailData.token}
`
}
}
}
await sendEmail(email, config.email.sender, message, config.aws)
}
================================================
FILE: src/services/oauth/apple.service.ts
================================================
import httpStatus from 'http-status'
import { ApiError } from '../../utils/api-error'
type AppleResponse = {
error?: string
id_token?: string
}
export const getIdTokenFromCode = async (
code: string,
clientId: string,
clientSecret: string,
redirectUrl: string
) => {
const params = {
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUrl,
response_mode: 'form_post'
}
const response = await fetch('https://appleid.apple.com/auth/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString()
})
const result = (await response.json()) as AppleResponse
if (result.error || !result.id_token) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized')
}
return result.id_token
}
================================================
FILE: src/services/oauth/facebook.service.ts
================================================
import httpStatus from 'http-status'
import * as queryString from 'query-string'
import { ApiError } from '../../utils/api-error'
type Options = {
clientId: string
redirectUrl: string
scope?: string
responseType?: string
authType?: string
display?: string
state?: string
}
// TODO: remove when worker-auth-providers library fixed
export const redirect = async (options: Options) => {
const {
clientId,
redirectUrl,
scope = 'email, user_friends',
responseType = 'code',
authType = 'rerequest',
display = 'popup',
state
} = options
if (!clientId) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request')
}
const params = queryString.stringify({
client_id: clientId,
redirect_uri: redirectUrl,
scope,
response_type: responseType,
auth_type: authType,
display,
state
})
const url = `https://www.facebook.com/v4.0/dialog/oauth?${params}`
return url
}
================================================
FILE: src/services/oauth/github.service.ts
================================================
import httpStatus from 'http-status'
import * as queryString from 'query-string'
import { ApiError } from '../../utils/api-error'
const DEFAULT_SCOPE = ['read:user', 'user:email']
const DEFAULT_ALLOW_SIGNUP = true
type Options = {
clientId: string
redirectTo?: string
scope?: string[]
allowSignup?: boolean
state?: string
}
type Params = {
client_id: string
redirect_uri?: string
scope: string
allow_signup: boolean
state?: string
}
// TODO: remove when worker-auth-providers library fixed
export const redirect = async (options: Options) => {
const {
clientId,
redirectTo,
scope = DEFAULT_SCOPE,
allowSignup = DEFAULT_ALLOW_SIGNUP,
state
} = options
if (!clientId) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request')
}
const params: Params = {
client_id: clientId,
scope: scope.join(' '),
allow_signup: allowSignup,
state
}
if (redirectTo) {
params.redirect_uri = redirectTo
}
const paramString = queryString.stringify(params)
const githubLoginUrl = `https://github.com/login/oauth/authorize?${paramString}`
return githubLoginUrl
}
================================================
FILE: src/services/oauth/spotify.service.ts
================================================
import httpStatus from 'http-status'
import * as queryString from 'query-string'
import { ApiError } from '../../utils/api-error'
type Options = {
clientId: string
redirectUrl?: string
scope?: string
responseType?: string
showDialog?: boolean
state?: string
}
// TODO: remove when worker-auth-providers library fixed
export const redirect = async (options: Options) => {
const {
clientId,
redirectUrl,
scope = 'user-library-read playlist-modify-private',
responseType = 'code',
showDialog = false,
state
} = options
if (!clientId) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request')
}
const params = queryString.stringify({
client_id: clientId,
redirect_uri: redirectUrl,
response_type: responseType,
scope,
show_dialog: showDialog,
state
})
const url = `https://accounts.spotify.com/authorize?${params}`
return url
}
================================================
FILE: src/services/token.service.ts
================================================
import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt'
import dayjs, { Dayjs } from 'dayjs'
import httpStatus from 'http-status'
import { Selectable } from 'kysely'
import { Config } from '../config/config'
import { getDBClient } from '../config/database'
import { Role } from '../config/roles'
import { TokenType, tokenTypes } from '../config/tokens'
import { OneTimeOauthCode } from '../models/one-time-oauth-code'
import { TokenResponse } from '../models/token.model'
import { User } from '../models/user.model'
import { ApiError } from '../utils/api-error'
import { generateId } from '../utils/utils'
export const generateToken = async (
userId: string,
type: TokenType,
role: Role,
expires: Dayjs,
secret: string,
isEmailVerified: boolean
) => {
const payload = {
sub: userId.toString(),
exp: expires.unix(),
iat: dayjs().unix(),
type,
role,
isEmailVerified
}
return jwt.sign(payload, secret)
}
export const generateAuthTokens = async (user: Selectable<User>, jwtConfig: Config['jwt']) => {
const accessTokenExpires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes')
const accessToken = await generateToken(
user.id,
tokenTypes.ACCESS,
user.role,
accessTokenExpires,
jwtConfig.secret,
user.is_email_verified
)
const refreshTokenExpires = dayjs().add(jwtConfig.refreshExpirationDays, 'days')
const refreshToken = await generateToken(
user.id,
tokenTypes.REFRESH,
user.role,
refreshTokenExpires,
jwtConfig.secret,
user.is_email_verified
)
return {
access: {
token: accessToken,
expires: accessTokenExpires.toDate()
},
refresh: {
token: refreshToken,
expires: refreshTokenExpires.toDate()
}
}
}
export const verifyToken = async (token: string, type: TokenType, secret: string) => {
const isValid = await jwt.verify(token, secret)
if (!isValid) {
throw new Error('Token not valid')
}
const decoded = jwt.decode(token)
const payload = decoded.payload as JwtPayload
if (type !== payload.type) {
throw new Error('Token not valid')
}
return payload
}
export const generateVerifyEmailToken = async (
user: Selectable<User>,
jwtConfig: Config['jwt']
) => {
const expires = dayjs().add(jwtConfig.verifyEmailExpirationMinutes, 'minutes')
const verifyEmailToken = await generateToken(
user.id,
tokenTypes.VERIFY_EMAIL,
user.role,
expires,
jwtConfig.secret,
user.is_email_verified
)
return verifyEmailToken
}
export const generateResetPasswordToken = async (
user: Selectable<User>,
jwtConfig: Config['jwt']
) => {
const expires = dayjs().add(jwtConfig.resetPasswordExpirationMinutes, 'minutes')
const resetPasswordToken = await generateToken(
user.id,
tokenTypes.RESET_PASSWORD,
user.role,
expires,
jwtConfig.secret,
user.is_email_verified
)
return resetPasswordToken
}
const generateOneTimeOauthCodeToken = () => {
return generateId()
}
export const createOneTimeOauthCode = async (
userId: string,
tokens: TokenResponse,
config: Config
) => {
const db = getDBClient(config.database)
let attempts = 0
const maxAttempts = 5
let code = generateOneTimeOauthCodeToken()
while (attempts < maxAttempts) {
try {
await db
.insertInto('one_time_oauth_code')
.values({
code,
user_id: userId,
access_token: tokens.access.token,
access_token_expires_at: tokens.access.expires,
refresh_token: tokens.refresh.token,
refresh_token_expires_at: tokens.refresh.expires,
expires_at: dayjs().add(config.jwt.accessExpirationMinutes, 'minutes').toDate()
})
.executeTakeFirstOrThrow()
break
} catch {
if (attempts >= maxAttempts) {
throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Failed to create one time code')
}
code = generateOneTimeOauthCodeToken()
attempts++
}
}
return code
}
export const getOneTimeOauthCode = async (code: string, config: Config) => {
const db = getDBClient(config.database)
const oneTimeCode = await db.transaction().execute(async (trx) => {
const oneTimeCode = await db
.selectFrom('one_time_oauth_code')
.selectAll()
.where('code', '=', code)
.where('expires_at', '>', dayjs().toDate())
.executeTakeFirst()
if (!oneTimeCode) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Code invalid or expired')
}
await trx.deleteFrom('one_time_oauth_code').where('code', '=', code).execute()
return oneTimeCode
})
return new OneTimeOauthCode(oneTimeCode)
}
================================================
FILE: src/services/user.service.ts
================================================
import httpStatus from 'http-status'
import { UpdateResult } from 'kysely'
import { Config } from '../config/config'
import { getDBClient } from '../config/database'
import { OAuthUserModel } from '../models/oauth/oauth-base.model'
import { User } from '../models/user.model'
import { UserTable } from '../tables/user.table'
import { AuthProviderType } from '../types/oauth.types'
import { ApiError } from '../utils/api-error'
import { generateId } from '../utils/utils'
import { CreateUser, UpdateUser } from '../validations/user.validation'
interface getUsersFilter {
email: string | undefined
}
interface getUsersOptions {
sortBy: string
limit: number
page: number
}
export const createUser = async (
userBody: CreateUser,
databaseConfig: Config['database']
): Promise<User> => {
const db = getDBClient(databaseConfig)
const id = generateId()
try {
await db
.insertInto('user')
.values({ ...userBody, id })
.executeTakeFirstOrThrow()
} catch {
throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists')
}
const user = (await getUserById(id, databaseConfig)) as User
return user
}
export const createOauthUser = async (
providerUser: OAuthUserModel,
databaseConfig: Config['database']
): Promise<User> => {
const db = getDBClient(databaseConfig)
try {
const id = generateId()
await db.transaction().execute(async (trx) => {
await trx
.insertInto('user')
.values({
id,
name: providerUser._name,
email: providerUser._email,
is_email_verified: true,
password: null,
role: 'user'
})
.executeTakeFirstOrThrow()
await trx
.insertInto('authorisations')
.values({
user_id: id,
provider_type: providerUser.providerType,
provider_user_id: providerUser._id
})
.executeTakeFirstOrThrow()
})
} catch {
throw new ApiError(
httpStatus.FORBIDDEN,
`Cannot signup with ${providerUser.providerType}, user already exists with that email`
)
}
const user = (await getUserByProviderIdType(
providerUser._id,
providerUser.providerType,
databaseConfig
)) as User
return new User(user)
}
export const queryUsers = async (
filter: getUsersFilter,
options: getUsersOptions,
databaseConfig: Config['database']
): Promise<User[]> => {
const db = getDBClient(databaseConfig)
const [sortField, direction] = options.sortBy.split(':') as [keyof UserTable, 'asc' | 'desc']
let usersQuery = db
.selectFrom('user')
.selectAll()
.orderBy(`user.${sortField}`, direction)
.limit(options.limit)
.offset(options.limit * options.page)
if (filter.email) {
usersQuery = usersQuery.where('user.email', '=', filter.email)
}
const users = await usersQuery.execute()
return users.map((user) => new User(user))
}
export const getUserById = async (
id: string,
databaseConfig: Config['database']
): Promise<User | undefined> => {
const db = getDBClient(databaseConfig)
const user = await db.selectFrom('user').selectAll().where('user.id', '=', id).executeTakeFirst()
return user ? new User(user) : undefined
}
export const getUserByEmail = async (
email: string,
databaseConfig: Config['database']
): Promise<User | undefined> => {
const db = getDBClient(databaseConfig)
const user = await db
.selectFrom('user')
.selectAll()
.where('user.email', '=', email)
.executeTakeFirst()
return user ? new User(user) : undefined
}
export const getUserByProviderIdType = async (
id: string,
type: AuthProviderType,
databaseConfig: Config['database']
): Promise<User | undefined> => {
const db = getDBClient(databaseConfig)
const user = await db
.selectFrom('user')
.innerJoin('authorisations', 'authorisations.user_id', 'user.id')
.selectAll()
.where('authorisations.provider_user_id', '=', id)
.where('authorisations.provider_type', '=', type)
.executeTakeFirst()
return user ? new User(user) : undefined
}
export const updateUserById = async (
userId: string,
updateBody: Partial<UpdateUser>,
databaseConfig: Config['database']
): Promise<User> => {
const db = getDBClient(databaseConfig)
let result: UpdateResult
try {
result = await db
.updateTable('user')
.set(updateBody)
.where('id', '=', userId)
.executeTakeFirst()
} catch {
throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists')
}
if (!result.numUpdatedRows || Number(result.numUpdatedRows) < 1) {
throw new ApiError(httpStatus.NOT_FOUND, 'User not found')
}
const user = (await getUserById(userId, databaseConfig)) as User
return user
}
export const deleteUserById = async (
userId: string,
databaseConfig: Config['database']
): Promise<void> => {
const db = getDBClient(databaseConfig)
const result = await db.deleteFrom('user').where('user.id', '=', userId).executeTakeFirst()
if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) {
throw new ApiError(httpStatus.NOT_FOUND, 'User not found')
}
}
export const getAuthorisations = async (userId: string, databaseConfig: Config['database']) => {
const db = getDBClient(databaseConfig)
const auths = await db
.selectFrom('user')
.leftJoin('authorisations', 'authorisations.user_id', 'user.id')
.selectAll()
.where('user.id', '=', userId)
.execute()
if (!auths) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')
}
const response = {
local: auths[0].password !== null ? true : false,
google: false,
facebook: false,
discord: false,
spotify: false,
github: false,
apple: false
}
for (const auth of auths) {
if (auth.provider_type === null) {
continue
}
response[auth.provider_type as AuthProviderType] = true
}
return response
}
================================================
FILE: src/tables/oauth.table.ts
================================================
export interface AuthProviderTable {
provider_user_id: string
provider_type: string
user_id: string
}
================================================
FILE: src/tables/one-time-oauth-code.table.ts
================================================
import { Generated } from 'kysely'
export interface OneTimeOauthCodeTable {
code: string
user_id: string
access_token: string
access_token_expires_at: Date
refresh_token: string
refresh_token_expires_at: Date
expires_at: Date
created_at: Generated<Date>
updated_at: Generated<Date>
}
================================================
FILE: src/tables/user.table.ts
================================================
import { Role } from '../config/roles'
export interface UserTable {
id: string
name: string | null // null if not available on oauth account linking
email: string
password: string | null // null if user is created via OAuth
is_email_verified: boolean
role: Role
}
================================================
FILE: src/types/oauth.types.ts
================================================
import { authProviders } from '../config/authProviders'
import { AppleUser } from '../models/oauth/apple-user.model'
import { DiscordUser } from '../models/oauth/discord-user.model'
import { FacebookUser } from '../models/oauth/facebook-user.model'
import { GithubUser } from '../models/oauth/github-user.model'
import { GoogleUser } from '../models/oauth/google-user.model'
import { SpotifyUser } from '../models/oauth/spotify-user.model'
export type AuthProviderType = (typeof authProviders)[keyof typeof authProviders]
export interface OAuthUserType {
_id: string
_email: string
_name?: string
providerType: AuthProviderType
}
export interface AppleUserType {
sub: string
email?: string
name?: string
}
export interface DiscordUserType {
id: string
email: string
username: string
}
export interface FacebookUserType {
id: string
email: string
first_name: string
last_name: string
}
export interface GithubUserType {
id: number
email: string
name: string
}
export interface GoogleUserType {
id: string
email: string
name: string
}
export interface SpotifyUserType {
id: string
email: string
display_name: string
}
export interface OauthUserTypes {
facebook: FacebookUserType
discord: DiscordUserType
google: GoogleUserType
spotify: SpotifyUserType
apple: AppleUserType
github: GithubUserType
}
export type ProviderUserMapping = {
[key in AuthProviderType]: new (
user: OauthUserTypes[key]
) => FacebookUser | DiscordUser | GoogleUser | SpotifyUser | AppleUser | GithubUser
}
================================================
FILE: src/utils/api-error.ts
================================================
export class ApiError extends Error {
statusCode: number
isOperational: boolean
constructor(statusCode: number, message: string, isOperational = true) {
super(message)
this.statusCode = statusCode
this.isOperational = isOperational
}
}
================================================
FILE: src/utils/utils.ts
================================================
import { nanoid } from 'nanoid'
export const generateId = () => {
return nanoid()
}
================================================
FILE: src/utils/zod.ts
================================================
import { ZodError } from 'zod'
import { fromError } from 'zod-validation-error'
export const generateZodErrorMessage = (error: ZodError): string => {
return fromError(error).message
}
================================================
FILE: src/validations/auth.validation.ts
================================================
import { z } from 'zod'
import { password } from './custom.refine.validation'
import { hashPassword } from './custom.transform.validation'
export const register = z.strictObject({
email: z.string().email(),
password: z.string().superRefine(password).transform(hashPassword),
name: z.string()
})
export type Register = z.infer<typeof register>
export const login = z.strictObject({
email: z.string(),
password: z.string()
})
export const refreshTokens = z.strictObject({
refresh_token: z.string()
})
export const forgotPassword = z.strictObject({
email: z.string().email()
})
export const resetPassword = z.strictObject({
query: z.object({
token: z.string()
}),
body: z.object({
password: z.string().superRefine(password).transform(hashPassword)
})
})
export const verifyEmail = z.strictObject({
token: z.string()
})
export const changePassword = z.strictObject({
oldPassword: z.string().superRefine(password).transform(hashPassword),
newPassword: z.string().superRefine(password).transform(hashPassword)
})
export const oauthCallback = z.object({
code: z.string(),
platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')])
})
export const linkApple = z.object({
code: z.string()
})
export const oauthRedirect = z.object({
state: z.string()
})
export const validateOneTimeCode = z.object({
code: z.string()
})
export const stateValidation = z.object({
platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')])
})
================================================
FILE: src/validations/custom.refine.validation.ts
================================================
import { z } from 'zod'
export const password = async (value: string, ctx: z.RefinementCtx): Promise<void> => {
if (value.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'password must be at least 8 characters'
})
return
}
if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'password must contain at least 1 letter and 1 number'
})
return
}
}
================================================
FILE: src/validations/custom.transform.validation.ts
================================================
import bcrypt from 'bcryptjs'
export const hashPassword = async (value: string): Promise<string> => {
const hashedPassword = await bcrypt.hash(value, 8)
return hashedPassword
}
================================================
FILE: src/validations/custom.type.validation.ts
================================================
import { z } from 'zod'
export const roleZodType = z.union([z.literal('admin'), z.literal('user')])
================================================
FILE: src/validations/user.validation.ts
================================================
import { z } from 'zod'
import { password } from './custom.refine.validation'
import { hashPassword } from './custom.transform.validation'
import { roleZodType } from './custom.type.validation'
export const createUser = z.strictObject({
email: z.string().email(),
password: z.string().superRefine(password).transform(hashPassword),
name: z.string(),
is_email_verified: z
.any()
.optional()
.transform(() => false),
role: roleZodType
})
export type CreateUser = z.infer<typeof createUser>
export const getUsers = z.object({
email: z.string().optional(),
sort_by: z.string().optional().default('id:asc'),
limit: z.coerce.number().optional().default(10),
page: z.coerce.number().optional().default(0)
})
export const getUser = z.object({ userId: z.string() })
export const updateUser = z.strictObject({
params: z.object({ userId: z.string() }),
body: z
.object({
email: z.string().email().optional(),
name: z.string().optional(),
role: z.union([z.literal('admin'), z.literal('user')]).optional()
})
.refine(({ email, name, role }) => email || name || role, {
message: 'At least one field is required'
})
})
export type UpdateUser =
| z.infer<typeof updateUser>['body']
| { password: string }
| { is_email_verified: boolean }
export const deleteUser = z.strictObject({ userId: z.string() })
================================================
FILE: tests/cloudflare-test.d.ts
================================================
declare module 'cloudflare:test' {
import { Environment } from '../bindings'
type ProvidedEnv = Environment
}
================================================
FILE: tests/fixtures/authorisations.fixture.ts
================================================
import { faker } from '@faker-js/faker'
import { Insertable } from 'kysely'
import { authProviders } from '../../src/config/authProviders'
import { Config } from '../../src/config/config'
import { getDBClient } from '../../src/config/database'
import { AuthProviderTable } from '../../src/tables/oauth.table'
export const githubAuthorisation = (userId: string) => ({
provider_type: authProviders.GITHUB,
provider_user_id: faker.number.int().toString(),
user_id: userId
})
export const discordAuthorisation = (userId: string) => ({
provider_type: authProviders.DISCORD,
provider_user_id: faker.number.int().toString(),
user_id: userId
})
export const spotifyAuthorisation = (userId: string) => ({
provider_type: authProviders.SPOTIFY,
provider_user_id: faker.number.int().toString(),
user_id: userId
})
export const googleAuthorisation = (userId: string) => ({
provider_type: authProviders.GOOGLE,
provider_user_id: faker.number.int().toString(),
user_id: userId
})
export const facebookAuthorisation = (userId: string) => ({
provider_type: authProviders.FACEBOOK,
provider_user_id: faker.number.int().toString(),
user_id: userId
})
export const appleAuthorisation = (userId: string) => ({
provider_type: authProviders.APPLE,
provider_user_id: faker.number.int().toString(),
user_id: userId
})
export const insertAuthorisations = async (
authorisations: Insertable<AuthProviderTable>[],
databaseConfig: Config['database']
) => {
const client = getDBClient(databaseConfig)
for await (const authorisation of authorisations) {
await client.insertInto('authorisations').values(authorisation).executeTakeFirst()
}
}
================================================
FILE: tests/fixtures/token.fixture.ts
================================================
import dayjs from 'dayjs'
import { Config } from '../../src/config/config'
import { Role } from '../../src/config/roles'
import { tokenTypes, TokenType } from '../../src/config/tokens'
import * as tokenService from '../../src/services/token.service'
export interface TokenResponse {
access: {
token: string
expires: string
}
refresh: {
token: string
expires: string
}
}
export const getAccessToken = async (
userId: string,
role: Role,
jwtConfig: Config['jwt'],
type: TokenType = tokenTypes.ACCESS,
isEmailVerified = true
) => {
const expires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes')
const token = await tokenService.generateToken(
userId,
type,
role,
expires,
jwtConfig.secret,
isEmailVerified
)
return token
}
================================================
FILE: tests/fixtures/user.fixture.ts
================================================
import { faker } from '@faker-js/faker'
import bcrypt from 'bcryptjs'
import { Insertable } from 'kysely'
import { Config } from '../../src/config/config'
import { getDBClient } from '../../src/config/database'
import { UserTable } from '../../src/tables/user.table'
import { generateId } from '../../src/utils/utils'
const password = 'password1'
const salt = bcrypt.genSaltSync(8)
const hashedPassword = bcrypt.hashSync(password, salt)
export type MockUser = Insertable<UserTable>
export interface UserResponse {
id: string
name: string
email: string
role: string
is_email_verified: boolean
}
export const userOne: MockUser = {
id: generateId(),
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
password,
role: 'user',
is_email_verified: false
}
export const userTwo: MockUser = {
id: generateId(),
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
password,
role: 'user',
is_email_verified: false
}
export const admin: MockUser = {
id: generateId(),
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
password,
role: 'admin',
is_email_verified: false
}
export const insertUsers = async (users: MockUser[], databaseConfig: Config['database']) => {
const hashedUsers = users.map((user) => ({
...user,
password: user.password ? hashedPassword : null
}))
const client = getDBClient(databaseConfig)
for await (const user of hashedUsers) {
await client.insertInto('user').values(user).executeTakeFirst()
}
}
================================================
FILE: tests/integration/auth/auth.test.ts
================================================
// TODO: Add SES mock client back. It's not working with vitest
// import { mockClient } from 'aws-sdk-client-mock'
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'
import { faker } from '@faker-js/faker'
import bcrypt from 'bcryptjs'
import { env } from 'cloudflare:test'
import dayjs from 'dayjs'
import httpStatus from 'http-status'
import { describe, expect, test, beforeEach } from 'vitest'
import { getConfig } from '../../../src/config/config'
import { getDBClient } from '../../../src/config/database'
import { tokenTypes } from '../../../src/config/tokens'
import * as tokenService from '../../../src/services/token.service'
import { Register } from '../../../src/validations/auth.validation'
import {
appleAuthorisation,
discordAuthorisation,
facebookAuthorisation,
githubAuthorisation,
googleAuthorisation,
insertAuthorisations,
spotifyAuthorisation
} from '../../fixtures/authorisations.fixture'
import { getAccessToken, TokenResponse } from '../../fixtures/token.fixture'
import { userOne, insertUsers, UserResponse, userTwo } from '../../fixtures/user.fixture'
import { expectExtension, mockClient } from '../../mocks/awsClientStub'
import { clearDBTables } from '../../utils/clear-db-tables'
import { request } from '../../utils/test-request'
const config = getConfig(env)
const client = getDBClient(config.database)
expect.extend(expectExtension)
clearDBTables(['user', 'authorisations'], config.database)
describe('Auth routes', () => {
describe('POST /v1/auth/register', () => {
let newUser: Register
beforeEach(() => {
newUser = {
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
password: 'password1'
}
})
test('should return 201 and successfully register user if request data is ok', async () => {
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.CREATED)
const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()
expect(body.user).not.toHaveProperty('password')
expect(body.user).toEqual({
id: expect.anything(),
name: newUser.name,
email: newUser.email,
role: 'user',
is_email_verified: 0
})
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', body.user.id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
expect(dbUser.password).not.toBe(newUser.password)
expect(dbUser).toMatchObject({
name: newUser.name,
password: expect.anything(),
email: newUser.email,
is_email_verified: 0,
role: 'user'
})
expect(body.tokens).toEqual({
access: { token: expect.anything(), expires: expect.anything() },
refresh: { token: expect.anything(), expires: expect.anything() }
})
})
test('should return 400 error if email is invalid', async () => {
newUser.email = 'invalidEmail'
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if email is already used', async () => {
await insertUsers([userOne], config.database)
newUser.email = userOne.email
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if password length is less than 8 characters', async () => {
newUser.password = 'passwo1'
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if role is set', async () => {
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify({ ...newUser, role: 'admin' }),
headers: { 'Content-Type': 'application/json' }
})
const body = await res.json<{ code: number; message: string }>()
expect(res.status).toBe(httpStatus.BAD_REQUEST)
expect(body.message).toContain("Validation error: Unrecognized key(s) in object: 'role'")
})
test('should return 400 error if is_email_verified is set', async () => {
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify({ ...newUser, is_email_verified: true }),
headers: { 'Content-Type': 'application/json' }
})
const body = await res.json<{ code: number; message: string }>()
expect(res.status).toBe(httpStatus.BAD_REQUEST)
expect(body.message).toContain(
"Validation error: Unrecognized key(s) in object: 'is_email_verified'"
)
})
test('should return 400 if password does not contain both letters and numbers', async () => {
newUser.password = 'password'
const res = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
newUser.password = '11111111'
const res2 = await request('/v1/auth/register', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' }
})
expect(res2.status).toBe(httpStatus.BAD_REQUEST)
})
})
describe('POST /v1/auth/login', () => {
test('should return 200 and login user if email and password match', async () => {
await insertUsers([userOne], config.database)
const loginCredentials = {
email: userOne.email,
password: userOne.password
}
const res = await request('/v1/auth/login', {
method: 'POST',
body: JSON.stringify(loginCredentials),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.OK)
const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()
expect(body.user).not.toHaveProperty('password')
expect(body.user).toEqual({
id: expect.anything(),
name: userOne.name,
email: userOne.email,
role: userOne.role,
is_email_verified: 0
})
expect(body.tokens).toEqual({
access: { token: expect.anything(), expires: expect.anything() },
refresh: { token: expect.anything(), expires: expect.anything() }
})
})
test('should return 401 error if there are no users with that email', async () => {
const loginCredentials = {
email: userOne.email,
password: userOne.password
}
const res = await request('/v1/auth/login', {
method: 'POST',
body: JSON.stringify(loginCredentials),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
const body = await res.json()
expect(body).toEqual({
code: httpStatus.UNAUTHORIZED,
message: 'Incorrect email or password'
})
})
test('should return 401 error if only oauth account exists', async () => {
const newUser = { ...userOne, password: null }
await insertUsers([newUser], config.database)
const discordUser = discordAuthorisation(newUser.id)
await insertAuthorisations([discordUser], config.database)
const loginCredentials = {
email: newUser.email,
password: ''
}
const res = await request('/v1/auth/login', {
method: 'POST',
body: JSON.stringify(loginCredentials),
headers: { 'Content-Type': 'application/json' }
})
const body = await res.json()
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
expect(body).toEqual({
code: httpStatus.UNAUTHORIZED,
message: 'Please login with your social account'
})
})
test('should return 401 error if password is wrong', async () => {
await insertUsers([userOne], config.database)
const loginCredentials = {
email: userOne.email,
password: 'wrongPassword1'
}
const res = await request('/v1/auth/login', {
method: 'POST',
body: JSON.stringify(loginCredentials),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
const body = await res.json()
expect(body).toEqual({
code: httpStatus.UNAUTHORIZED,
message: 'Incorrect email or password'
})
})
})
describe('POST /v1/auth/refresh-tokens', () => {
test('should return 200 and new auth tokens if refresh token is valid', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().add(config.jwt.refreshExpirationDays, 'days')
const refreshToken = await tokenService.generateToken(
userOne.id,
tokenTypes.REFRESH,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request('/v1/auth/refresh-tokens', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
headers: { 'Content-Type': 'application/json' }
})
const body = await res.json<{ tokens: TokenResponse }>()
expect(res.status).toBe(httpStatus.OK)
expect(body).toEqual({
access: { token: expect.anything(), expires: expect.anything() },
refresh: { token: expect.anything(), expires: expect.anything() }
})
})
test('should return 400 error if refresh token is missing from request body', async () => {
const res = await request('/v1/auth/refresh-tokens', {
method: 'POST',
body: JSON.stringify({}),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 401 error if refresh token is signed using an invalid secret', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().add(config.jwt.refreshExpirationDays, 'days')
const refreshToken = await tokenService.generateToken(
userOne.id,
tokenTypes.REFRESH,
userOne.role,
expires,
'random secret',
userOne.is_email_verified
)
const res = await request('/v1/auth/refresh-tokens', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 401 error if refresh token is expired', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().subtract(1, 'minutes')
const refreshToken = await tokenService.generateToken(
userOne.id,
tokenTypes.REFRESH,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request('/v1/auth/refresh-tokens', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 401 error if user is not found', async () => {
const expires = dayjs().add(1, 'minutes')
const refreshToken = await tokenService.generateToken(
'123',
tokenTypes.REFRESH,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request('/v1/auth/refresh-tokens', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
})
describe('POST /v1/auth/forgot-password', () => {
const sesMock = mockClient(SESClient)
beforeEach(() => {
sesMock.reset()
})
test('should return 204 and send reset password email to the user', async () => {
await insertUsers([userOne], config.database)
sesMock.on(SendEmailCommand).resolves({
MessageId: 'message-id'
})
const res = await request('/v1/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email: userOne.email }),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)
})
test('should return 204 and send email if only has oauth account', async () => {
const newUser = { ...userOne, password: null }
await insertUsers([newUser], config.database)
const discordUser = discordAuthorisation(newUser.id)
await insertAuthorisations([discordUser], config.database)
sesMock.on(SendEmailCommand).resolves({
MessageId: 'message-id'
})
const res = await request('/v1/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email: newUser.email }),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)
})
test('should return 400 if email is missing', async () => {
await insertUsers([userOne], config.database)
const res = await request('/v1/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 204 if email does not belong to any user', async () => {
const res = await request('/v1/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email: userOne.email }),
headers: { 'Content-Type': 'application/json' }
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
})
})
describe('POST /v1/auth/send-verification-email', () => {
const sesMock = mockClient(SESClient)
beforeEach(() => {
sesMock.reset()
})
test('should return 204 and send verification email to the user', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
sesMock.on(SendEmailCommand).resolves({
MessageId: 'message-id'
})
const res = await request('/v1/auth/send-verification-email', {
method: 'POST',
body: JSON.stringify({ email: userOne.email }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)
})
test('should return 204 and not send verification email if already verified', async () => {
const newUser = { ...userOne }
newUser.is_email_verified = true
await insertUsers([newUser], config.database)
const newUserAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)
sesMock.on(SendEmailCommand).resolves({
MessageId: 'message-id'
})
const res = await request('/v1/auth/send-verification-email', {
method: 'POST',
body: JSON.stringify({ email: newUser.email }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${newUserAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 0)
})
test('should return 429 if a second request is sent in under 2 minutes', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
sesMock.on(SendEmailCommand).resolves({
MessageId: 'message-id'
})
const res = await request('/v1/auth/send-verification-email', {
method: 'POST',
body: JSON.stringify({ email: userOne.email }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const res2 = await request('/v1/auth/send-verification-email', {
method: 'POST',
body: JSON.stringify({ email: userOne.email }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res2.status).toBe(httpStatus.TOO_MANY_REQUESTS)
expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)
})
test('should return 401 error if access token is missing', async () => {
await insertUsers([userOne], config.database)
sesMock.on(SendEmailCommand).resolves({
MessageId: 'message-id'
})
const res = await request('/v1/auth/send-verification-email', {
method: 'POST',
body: JSON.stringify({ email: userOne.email }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
})
describe('POST /v1/auth/reset-password', () => {
test('should return 204 and reset the password', async () => {
await insertUsers([userOne], config.database)
const newPassword = 'iamanewpassword123'
const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes')
const resetPasswordToken = await tokenService.generateToken(
userOne.id,
tokenTypes.RESET_PASSWORD,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({ password: newPassword }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', userOne.id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
const isPasswordMatch = await bcrypt.compare(newPassword, dbUser.password || '')
expect(isPasswordMatch).toBe(true)
})
test('should return 400 if reset password token is missing', async () => {
const res = await request('/v1/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ password: 'iamanewpasword123' }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 401 if reset password token is expired', async () => {
await insertUsers([userOne], config.database)
const newPassword = 'iamanewpassword123'
const expires = dayjs().subtract(10, 'minutes')
const resetPasswordToken = await tokenService.generateToken(
userOne.id,
tokenTypes.RESET_PASSWORD,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({ password: newPassword }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 401 if user is not found', async () => {
const newPassword = 'iamanewpassword123'
const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes')
const resetPasswordToken = await tokenService.generateToken(
'123',
tokenTypes.RESET_PASSWORD,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({ password: newPassword }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 400 if password is missing or invalid', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes')
const resetPasswordToken = await tokenService.generateToken(
userOne.id,
tokenTypes.RESET_PASSWORD,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
const res2 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({ password: 'short1' }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res2.status).toBe(httpStatus.BAD_REQUEST)
const res3 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({ password: 'password' }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res3.status).toBe(httpStatus.BAD_REQUEST)
const res4 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {
method: 'POST',
body: JSON.stringify({ password: '11111111' }),
headers: {
'Content-Type': 'application/json'
}
})
expect(res4.status).toBe(httpStatus.BAD_REQUEST)
})
})
describe('POST /v1/auth/verify-email', () => {
test('should return 204 and verify the email', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().add(config.jwt.verifyEmailExpirationMinutes, 'minutes')
const verifyEmailToken = await tokenService.generateToken(
userOne.id,
tokenTypes.VERIFY_EMAIL,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', userOne.id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
expect(dbUser.is_email_verified).toBe(1)
})
test('should return 400 if verify email token is missing', async () => {
const res = await request('/v1/auth/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 401 if verify email token is expired', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().subtract(10, 'minutes')
const verifyEmailToken = await tokenService.generateToken(
userOne.id,
tokenTypes.VERIFY_EMAIL,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 401 if verify email token is an access token', async () => {
await insertUsers([userOne], config.database)
const expires = dayjs().add(10, 'minutes')
const verifyEmailToken = await tokenService.generateToken(
userOne.id,
tokenTypes.ACCESS,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 401 if user is not found', async () => {
const expires = dayjs().add(config.jwt.verifyEmailExpirationMinutes, 'minutes')
const verifyEmailToken = await tokenService.generateToken(
'123',
tokenTypes.VERIFY_EMAIL,
userOne.role,
expires,
config.jwt.secret,
userOne.is_email_verified
)
const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
})
describe('GET /v1/auth/authorisations', () => {
test('should 200 and list of user authentication methods with local true', async () => {
await insertUsers([userOne], config.database)
const accessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const res = await request('/v1/auth/authorisations', {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`
}
})
const body = await res.json()
expect(res.status).toBe(httpStatus.OK)
expect(body).toEqual({
local: true,
facebook: false,
github: false,
google: false,
spotify: false,
discord: false,
apple: false
})
})
test('should 200 and list of user authentication methods with discord true', async () => {
const user = { ...userOne }
user.password = null
await insertUsers([user], config.database)
const discordAuth = discordAuthorisation(user.id)
await insertAuthorisations([discordAuth], config.database)
const accessToken = await getAccessToken(user.id, user.role, config.jwt)
const res = await request('/v1/auth/authorisations', {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`
}
})
const body = await res.json()
expect(res.status).toBe(httpStatus.OK)
expect(body).toEqual({
local: false,
facebook: false,
github: false,
google: false,
spotify: false,
discord: true,
apple: false
})
})
test('should 200 and list of user authentication methods with all true', async () => {
await insertUsers([userOne], config.database)
const discordAuth = discordAuthorisation(userOne.id)
const spotifyAuth = spotifyAuthorisation(userOne.id)
const googleAuth = googleAuthorisation(userOne.id)
const githubAuth = githubAuthorisation(userOne.id)
const facebookAuth = facebookAuthorisation(userOne.id)
const appleAuth = appleAuthorisation(userOne.id)
await insertAuthorisations(
[discordAuth, spotifyAuth, googleAuth, facebookAuth, githubAuth, appleAuth],
config.database
)
const accessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const res = await request('/v1/auth/authorisations', {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`
}
})
const body = await res.json()
expect(res.status).toBe(httpStatus.OK)
expect(body).toEqual({
local: true,
facebook: true,
github: true,
google: true,
spotify: true,
discord: true,
apple: true
})
})
test('should return 403 if user has not verified their email', async () => {
await insertUsers([userTwo], config.database)
const accessToken = await getAccessToken(
userTwo.id,
userTwo.role,
config.jwt,
tokenTypes.ACCESS,
userTwo.is_email_verified
)
const res = await request('/v1/auth/authorisations', {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`
}
})
expect(res.status).toBe(httpStatus.FORBIDDEN)
})
})
describe('Auth middleware', () => {
test('should return 401 if auth header is malformed', async () => {
const res = await request('/v1/users/123', {
method: 'GET',
headers: {
Authorization: 'Bearer123'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
})
})
================================================
FILE: tests/integration/auth/oauth/apple.test.ts
================================================
import { faker } from '@faker-js/faker'
import jwt from '@tsndr/cloudflare-worker-jwt'
import { env, fetchMock } from 'cloudflare:test'
import httpStatus from 'http-status'
import { describe, expect, test, beforeAll, afterEach } from 'vitest'
import { authProviders } from '../../../../src/config/authProviders'
import { getConfig } from '../../../../src/config/config'
import { getDBClient } from '../../../../src/config/database'
import { tokenTypes } from '../../../../src/config/tokens'
import { AppleUserType } from '../../../../src/types/oauth.types'
import {
appleAuthorisation,
githubAuthorisation,
googleAuthorisation,
insertAuthorisations
} from '../../../fixtures/authorisations.fixture'
import { getAccessToken } from '../../../fixtures/token.fixture'
import { userOne, insertUsers, userTwo } from '../../../fixtures/user.fixture'
import { clearDBTables } from '../../../utils/clear-db-tables'
import { request } from '../../../utils/test-request'
const config = getConfig(env)
const client = getDBClient(config.database)
clearDBTables(['authorisations', 'user'], config.database)
describe('Oauth Apple routes', () => {
describe('GET /v1/auth/apple/redirect', () => {
test('should return 302 and successfully redirect to apple', async () => {
const state = btoa(JSON.stringify({ platform: 'web' }))
const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.provider.apple.redirectUrl)
const res = await request(`/v1/auth/apple/redirect?state=${state}`, {
method: 'GET'
})
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toContain(
'https://appleid.apple.com/auth/authorize?client_id=myclientid&redirect_uri=' +
`${urlEncodedRedirectUrl}&response_mode=form_post&response_type=code&scope=email` +
`&state=${state}`
)
})
test('should return 400 error if state is not provided', async () => {
const res = await request('/v1/auth/apple/redirect', { method: 'GET' })
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if state platform is not provided', async () => {
const state = btoa(JSON.stringify({}))
const res = await request(`/v1/auth/apple/redirect?state=${state}`, {
method: 'GET'
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if state platform is invalid', async () => {
const state = btoa(JSON.stringify({ platform: 'fake' }))
const res = await request(`/v1/auth/apple/redirect?state=${state}`, {
method: 'GET'
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
})
describe('POST /v1/auth/apple/callback', () => {
let newUser: AppleUserType
let state: string
beforeAll(async () => {
newUser = {
sub: faker.number.int().toString(),
name: faker.person.fullName(),
email: faker.internet.email()
}
fetchMock.activate()
state = btoa(JSON.stringify({ platform: 'web' }))
})
afterEach(() => fetchMock.assertNoPendingInterceptors())
test('should return 200 and successfully register + redirect with one time code', async () => {
const mockJWT = await jwt.sign(newUser, 'randomSecret')
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(200, JSON.stringify({ id_token: mockJWT }))
const providerId = '123456'
const formData = new FormData()
formData.append('code', providerId)
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toContain(
`${config.oauth.platform.ios.redirectUrl}?oneTimeCode=`
)
const location = res.headers.get('location')
const oneTimeCode = location?.split('=')[1].split('&')[0]
expect(oneTimeCode).toBeDefined()
if (!oneTimeCode) return
const returnedState = location?.split('=')[2]
expect(returnedState).toBe(state)
const dbOneTimeCode = await client
.selectFrom('one_time_oauth_code')
.selectAll()
.where('one_time_oauth_code.code', '=', oneTimeCode)
.executeTakeFirst()
expect(dbOneTimeCode).toBeDefined()
if (!dbOneTimeCode) return
expect(dbOneTimeCode).toEqual({
code: oneTimeCode,
user_id: expect.any(String),
access_token: expect.any(String),
access_token_expires_at: expect.any(Date),
refresh_token: expect.any(String),
refresh_token_expires_at: expect.any(Date),
expires_at: expect.any(Date),
created_at: expect.any(Date),
updated_at: expect.any(Date)
})
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', dbOneTimeCode.user_id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
expect(dbUser.password).toBeNull()
expect(dbUser).toMatchObject({
name: newUser.name,
password: null,
email: newUser.email,
role: 'user',
is_email_verified: 1
})
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.APPLE)
.where('authorisations.user_id', '=', dbOneTimeCode.user_id)
.where('authorisations.provider_user_id', '=', newUser.sub)
.executeTakeFirst()
expect(oauthUser).toBeDefined()
if (!oauthUser) return
})
test('should redirect and successfully login user if already created', async () => {
await insertUsers([userOne], config.database)
const appleUser = appleAuthorisation(userOne.id)
await insertAuthorisations([appleUser], config.database)
newUser.sub = appleUser.provider_user_id
const mockJWT = await jwt.sign(newUser, 'randomSecret')
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(200, JSON.stringify({ id_token: mockJWT }))
const providerId = '123456'
const formData = new FormData()
formData.append('code', providerId)
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toContain(
`${config.oauth.platform.ios.redirectUrl}?oneTimeCode=`
)
expect(res.headers.get('location')).toContain(`state=${state}`)
})
test('should redirect with error if user exists but has not linked their apple', async () => {
await insertUsers([userOne], config.database)
newUser.email = userOne.email
const mockJWT = await jwt.sign(newUser, 'randomSecret')
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(200, JSON.stringify({ id_token: mockJWT }))
const providerId = '123456'
const formData = new FormData()
formData.append('code', providerId)
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
const encodedError =
'Cannot%20signup%20with%20apple,' + '%20user%20already%20exists%20with%20that%20email'
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.ios.redirectUrl}?error=${encodedError}&state=${state}`
)
})
//TODO: return custom error message for this scenario
test('should redirect with error if no apple email is provided', async () => {
delete newUser.email
const mockJWT = await jwt.sign(newUser, 'randomSecret')
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(200, JSON.stringify({ id_token: mockJWT }))
const providerId = '123456'
const formData = new FormData()
formData.append('state', state)
formData.append('code', providerId)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.ios.redirectUrl}?error=Unauthorized&state=${state}`
)
})
test('should redirect with error if code is invalid', async () => {
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))
const providerId = '123456'
const formData = new FormData()
formData.append('code', providerId)
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.ios.redirectUrl}?error=Unauthorized&state=${state}`
)
})
test('should redirect with error if no code provided', async () => {
const formData = new FormData()
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.ios.redirectUrl}?error=Bad%20request&state=${state}`
)
})
test('should return 400 error if state is not provided', async () => {
const providerId = '123456'
const formData = new FormData()
formData.append('code', providerId)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.web.redirectUrl}?error=Something%20went%20wrong`
)
})
test('should return 400 error if platform is not provided', async () => {
const providerId = '123456'
const formData = new FormData()
state = btoa(JSON.stringify({}))
formData.append('code', providerId)
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.web.redirectUrl}?error=Bad%20request&state=${state}`
)
})
test('should return 400 error if platform is invalid', async () => {
const providerId = '123456'
const formData = new FormData()
state = btoa(JSON.stringify({ platform: 'wb' }))
formData.append('code', providerId)
formData.append('state', state)
const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
`${config.oauth.platform.web.redirectUrl}?error=Bad%20request&state=${state}`
)
})
})
describe('POST /v1/auth/apple/:userId', () => {
let newUser: AppleUserType
beforeAll(async () => {
newUser = {
sub: faker.number.int().toString(),
name: faker.person.fullName(),
email: faker.internet.email()
}
fetchMock.activate()
})
afterEach(() => fetchMock.assertNoPendingInterceptors())
test('should return 200 and successfully link apple account', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const mockJWT = await jwt.sign(newUser, 'randomSecret')
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(200, JSON.stringify({ id_token: mockJWT }))
const providerId = '123456'
const res = await request(`/v1/auth/apple/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({ code: providerId }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', userOne.id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
expect(dbUser.password).toBeDefined()
expect(dbUser).toMatchObject({
name: userOne.name,
password: expect.anything(),
email: userOne.email,
role: userOne.role,
is_email_verified: 0
})
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.APPLE)
.where('authorisations.user_id', '=', userOne.id)
.where('authorisations.provider_user_id', '=', newUser.sub)
.executeTakeFirst()
expect(oauthUser).toBeDefined()
if (!oauthUser) return
})
test('should return 401 if user does not exist when linking', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()
const mockJWT = await jwt.sign(newUser, 'randomSecret')
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(200, JSON.stringify({ id_token: mockJWT }))
const providerId = '123456'
const res = await request(`/v1/auth/apple/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({ code: providerId }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.APPLE)
.where('authorisations.user_id', '=', userOne.id)
.where('authorisations.provider_user_id', '=', String(newUser.sub))
.executeTakeFirst()
expect(oauthUser).toBeUndefined()
})
test('should return 401 if code is invalid', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const appleMock = fetchMock.get('https://appleid.apple.com')
appleMock
.intercept({ method: 'POST', path: '/auth/token' })
.reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))
const providerId = '123456'
const res = await request(`/v1/auth/apple/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({ code: providerId }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 403 if linking different user', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const providerId = '123456'
const res = await request('/v1/auth/apple/5298', {
method: 'POST',
body: JSON.stringify({ code: providerId }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.FORBIDDEN)
})
test('should return 400 if no code provided', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const res = await request(`/v1/auth/apple/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 401 error if access token is missing', async () => {
const res = await request('/v1/auth/apple/1234', {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 403 if user has not verified their email', async () => {
await insertUsers([userTwo], config.database)
const accessToken = await getAccessToken(
userTwo.id,
userTwo.role,
config.jwt,
tokenTypes.ACCESS,
userTwo.is_email_verified
)
const res = await request('/v1/auth/apple/5298', {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
}
})
expect(res.status).toBe(httpStatus.FORBIDDEN)
})
})
describe('DELETE /v1/auth/apple/:userId', () => {
test('should return 200 and successfully remove apple account link', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const appleUser = appleAuthorisation(userOne.id)
await insertAuthorisations([appleUser], config.database)
const res = await request(`/v1/auth/apple/${userOne.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.APPLE)
.where('authorisations.user_id', '=', userOne.id)
.executeTakeFirst()
expect(oauthUser).toBeUndefined()
if (!oauthUser) return
})
test('should return 400 if user does not have a local login and only 1 link', async () => {
const newUser = { ...userOne, password: null }
await insertUsers([newUser], config.database)
const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)
const appleUser = appleAuthorisation(newUser.id)
await insertAuthorisations([appleUser], config.database)
const res = await request(`/v1/auth/apple/${newUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.APPLE)
.where('authorisations.user_id', '=', newUser.id)
.executeTakeFirst()
expect(oauthUser).toBeDefined()
})
test('should return 400 if user does not have apple link', async () => {
const newUser = { ...userOne, password: null }
await insertUsers([newUser], config.database)
const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)
const githubUser = githubAuthorisation(newUser.id)
await insertAuthorisations([githubUser], config.database)
const googleUser = googleAuthorisation(newUser.id)
await insertAuthorisations([googleUser], config.database)
const res = await request(`/v1/auth/apple/${newUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 if user only has a local login', async () => {
const newUser = { ...userOne, password: null }
await insertUsers([newUser], config.database)
const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)
const res = await request(`/v1/auth/apple/${newUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 200 if user does not have a local login and 2 links', async () => {
const newUser = { ...userOne, password: null }
await insertUsers([newUser], config.database)
const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)
const appleUser = appleAuthorisation(newUser.id)
const githubUser = githubAuthorisation(newUser.id)
await insertAuthorisations([appleUser, githubUser], config.database)
const res = await request(`/v1/auth/apple/${newUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const oauthAppleUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.APPLE)
.where('authorisations.user_id', '=', newUser.id)
.executeTakeFirst()
expect(oauthAppleUser).toBeUndefined()
const oauthFacebookUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.GITHUB)
.where('authorisations.user_id', '=', newUser.id)
.executeTakeFirst()
expect(oauthFacebookUser).toBeDefined()
})
test('should return 403 if unlinking different user', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const res = await request('/v1/auth/apple/5298', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.FORBIDDEN)
})
test('should return 401 error if access token is missing', async () => {
const res = await request('/v1/auth/apple/1234', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 403 if user has not verified their email', async () => {
await insertUsers([userTwo], config.database)
const accessToken = await getAccessToken(
userTwo.id,
userTwo.role,
config.jwt,
tokenTypes.ACCESS,
userTwo.is_email_verified
)
const res = await request('/v1/auth/apple/5298', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
}
})
expect(res.status).toBe(httpStatus.FORBIDDEN)
})
})
})
================================================
FILE: tests/integration/auth/oauth/discord.test.ts
================================================
import { faker } from '@faker-js/faker'
import { env, fetchMock } from 'cloudflare:test'
import httpStatus from 'http-status'
import { describe, expect, test, beforeAll, afterEach } from 'vitest'
import { authProviders } from '../../../../src/config/authProviders'
import { getConfig } from '../../../../src/config/config'
import { getDBClient } from '../../../../src/config/database'
import { tokenTypes } from '../../../../src/config/tokens'
import { DiscordUserType } from '../../../../src/types/oauth.types'
import {
discordAuthorisation,
facebookAuthorisation,
githubAuthorisation,
insertAuthorisations
} from '../../../fixtures/authorisations.fixture'
import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture'
import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture'
import { clearDBTables } from '../../../utils/clear-db-tables'
import { request } from '../../../utils/test-request'
const config = getConfig(env)
const client = getDBClient(config.database)
clearDBTables(['user', 'authorisations'], config.database)
describe('Oauth Discord routes', () => {
describe('GET /v1/auth/discord/redirect', () => {
test('should return 302 and successfully redirect to discord', async () => {
const state = btoa(JSON.stringify({ platform: 'web' }))
const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl)
const res = await request(`/v1/auth/discord/redirect?state=${state}`, {
method: 'GET'
})
expect(res.status).toBe(httpStatus.FOUND)
expect(res.headers.get('location')).toBe(
'https://discord.com/api/oauth2/authorize?client_id=' +
`${config.oauth.provider.discord.clientId}&redirect_uri=${urlEncodedRedirectUrl}&` +
`response_type=code&scope=identify%20email&state=${state}`
)
})
test('should return 400 error if state is not provided', async () => {
const res = await request('/v1/auth/discord/redirect', { method: 'GET' })
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if state platform is not provided', async () => {
const state = btoa(JSON.stringify({}))
const res = await request(`/v1/auth/discord/redirect?state=${state}`, {
method: 'GET'
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if state platform is invalid', async () => {
const state = btoa(JSON.stringify({ platform: 'fake' }))
const res = await request(`/v1/auth/discord/redirect?state=${state}`, {
method: 'GET'
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
})
describe('POST /v1/auth/discord/callback', () => {
let newUser: DiscordUserType
beforeAll(async () => {
newUser = {
id: faker.number.int().toString(),
username: faker.person.fullName(),
email: faker.internet.email()
}
fetchMock.activate()
})
afterEach(() => fetchMock.assertNoPendingInterceptors())
test('should return 200 and successfully register user if request data is ok', async () => {
const discordApiMock = fetchMock.get('https://discord.com')
discordApiMock
.intercept({ method: 'GET', path: '/api/users/@me' })
.reply(200, JSON.stringify(newUser))
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(200, JSON.stringify({ access_token: '1234' }))
const providerId = '123456'
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json'
}
})
const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()
expect(res.status).toBe(httpStatus.OK)
expect(body.user).not.toHaveProperty('password')
expect(body.user).toEqual({
id: expect.anything(),
name: newUser.username,
email: newUser.email,
role: 'user',
is_email_verified: 1
})
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', body.user.id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
expect(dbUser.password).toBeNull()
expect(dbUser).toMatchObject({
name: newUser.username,
password: null,
email: newUser.email,
role: 'user',
is_email_verified: 1
})
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.DISCORD)
.where('authorisations.user_id', '=', body.user.id)
.where('authorisations.provider_user_id', '=', newUser.id)
.executeTakeFirst()
expect(oauthUser).toBeDefined()
if (!oauthUser) return
expect(body.tokens).toEqual({
access: { token: expect.anything(), expires: expect.anything() },
refresh: { token: expect.anything(), expires: expect.anything() }
})
})
test('should return 200 and successfully login user if already created', async () => {
await insertUsers([userOne], config.database)
const discordUser = discordAuthorisation(userOne.id)
await insertAuthorisations([discordUser], config.database)
newUser.id = discordUser.provider_user_id
const discordApiMock = fetchMock.get('https://discord.com')
discordApiMock
.intercept({ method: 'GET', path: '/api/users/@me' })
.reply(200, JSON.stringify(newUser))
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(200, JSON.stringify({ access_token: '1234' }))
const providerId = '123456'
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json'
}
})
const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()
expect(res.status).toBe(httpStatus.OK)
expect(body.user).not.toHaveProperty('password')
expect(body.user).toEqual({
id: userOne.id,
name: userOne.name,
email: userOne.email,
role: userOne.role,
is_email_verified: 0
})
expect(body.tokens).toEqual({
access: { token: expect.anything(), expires: expect.anything() },
refresh: { token: expect.anything(), expires: expect.anything() }
})
})
test('should return 403 if user exists but has not linked their discord', async () => {
await insertUsers([userOne], config.database)
newUser.email = userOne.email
const discordApiMock = fetchMock.get('https://discord.com')
discordApiMock
.intercept({ method: 'GET', path: '/api/users/@me' })
.reply(200, JSON.stringify(newUser))
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(200, JSON.stringify({ access_token: '1234' }))
const providerId = '123456'
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json'
}
})
const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()
expect(res.status).toBe(httpStatus.FORBIDDEN)
expect(body).toEqual({
code: httpStatus.FORBIDDEN,
message: 'Cannot signup with discord, user already exists with that email'
})
})
test('should return 401 if code is invalid', async () => {
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))
const providerId = '123456'
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 400 if no code provided', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ platform: 'web' }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if platform is not provided', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const providerId = '123456'
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ code: providerId }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
test('should return 400 error if platform is invalid', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const providerId = '123456'
const res = await request('/v1/auth/discord/callback', {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'wb' }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.BAD_REQUEST)
})
})
describe('POST /v1/auth/discord/:userId', () => {
let newUser: DiscordUserType
beforeAll(async () => {
newUser = {
id: faker.number.int().toString(),
username: faker.person.fullName(),
email: faker.internet.email()
}
fetchMock.activate()
})
afterEach(() => fetchMock.assertNoPendingInterceptors())
test('should return 200 and successfully link discord account', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const discordApiMock = fetchMock.get('https://discord.com')
discordApiMock
.intercept({ method: 'GET', path: '/api/users/@me' })
.reply(200, JSON.stringify(newUser))
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(200, JSON.stringify({ access_token: '1234' }))
const providerId = '123456'
const res = await request(`/v1/auth/discord/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.NO_CONTENT)
const dbUser = await client
.selectFrom('user')
.selectAll()
.where('user.id', '=', userOne.id)
.executeTakeFirst()
expect(dbUser).toBeDefined()
if (!dbUser) return
expect(dbUser.password).toBeDefined()
expect(dbUser).toMatchObject({
name: userOne.name,
password: expect.anything(),
email: userOne.email,
role: userOne.role,
is_email_verified: 0
})
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.DISCORD)
.where('authorisations.user_id', '=', userOne.id)
.where('authorisations.provider_user_id', '=', newUser.id)
.executeTakeFirst()
expect(oauthUser).toBeDefined()
if (!oauthUser) return
})
test('should return 401 if user does not exist when linking', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()
const discordApiMock = fetchMock.get('https://discord.com')
discordApiMock
.intercept({ method: 'GET', path: '/api/users/@me' })
.reply(200, JSON.stringify(newUser))
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(200, JSON.stringify({ access_token: '1234' }))
const providerId = '123456'
const res = await request(`/v1/auth/discord/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
const oauthUser = await client
.selectFrom('authorisations')
.selectAll()
.where('authorisations.provider_type', '=', authProviders.DISCORD)
.where('authorisations.user_id', '=', userOne.id)
.where('authorisations.provider_user_id', '=', newUser.id)
.executeTakeFirst()
expect(oauthUser).toBeUndefined()
})
test('should return 401 if code is invalid', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const discordMock = fetchMock.get('https://discordapp.com')
discordMock
.intercept({ method: 'POST', path: '/api/oauth2/token' })
.reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))
const providerId = '123456'
const res = await request(`/v1/auth/discord/${userOne.id}`, {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userOneAccessToken}`
}
})
expect(res.status).toBe(httpStatus.UNAUTHORIZED)
})
test('should return 403 if linking different user', async () => {
await insertUsers([userOne], config.database)
const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)
const providerId = '123456'
const res = await request('/v1/auth/discord/5298', {
method: 'POST',
body: JSON.stringify({ code: providerId, platform: 'web' }),
headers: {
'Content-Type': 'application/json',
Authorizatio
gitextract_e9sl75y4/ ├── .dev.vars.example ├── .env.example ├── .env.test.example ├── .eslintignore ├── .gitignore ├── .husky/ │ ├── pre-commit │ └── pre-push ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── TODO.md ├── bin/ │ └── createApp.js ├── bindings.d.ts ├── build.js ├── eslint.config.js ├── jest.config.js ├── migrations/ │ └── 01_initial.ts ├── package.json ├── scripts/ │ └── migrate.ts ├── src/ │ ├── config/ │ │ ├── authProviders.ts │ │ ├── config.ts │ │ ├── database.ts │ │ ├── roles.ts │ │ └── tokens.ts │ ├── controllers/ │ │ ├── auth/ │ │ │ ├── auth.controller.ts │ │ │ └── oauth/ │ │ │ ├── apple.controller.ts │ │ │ ├── discord.controller.ts │ │ │ ├── facebook.controller.ts │ │ │ ├── github.controller.ts │ │ │ ├── google.controller.ts │ │ │ ├── oauth.controller.ts │ │ │ └── spotify.controller.ts │ │ └── user.controller.ts │ ├── durable-objects/ │ │ └── rate-limiter.do.ts │ ├── factories/ │ │ └── oauth.factory.ts │ ├── index.ts │ ├── middlewares/ │ │ ├── auth.ts │ │ ├── error.ts │ │ └── rate-limiter.ts │ ├── models/ │ │ ├── base.model.ts │ │ ├── oauth/ │ │ │ ├── apple-user.model.ts │ │ │ ├── discord-user.model.ts │ │ │ ├── facebook-user.model.ts │ │ │ ├── github-user.model.ts │ │ │ ├── google-user.model.ts │ │ │ ├── oauth-base.model.ts │ │ │ └── spotify-user.model.ts │ │ ├── one-time-oauth-code.ts │ │ ├── token.model.ts │ │ └── user.model.ts │ ├── routes/ │ │ ├── auth.route.ts │ │ ├── index.ts │ │ └── user.route.ts │ ├── services/ │ │ ├── auth.service.ts │ │ ├── email.service.ts │ │ ├── oauth/ │ │ │ ├── apple.service.ts │ │ │ ├── facebook.service.ts │ │ │ ├── github.service.ts │ │ │ └── spotify.service.ts │ │ ├── token.service.ts │ │ └── user.service.ts │ ├── tables/ │ │ ├── oauth.table.ts │ │ ├── one-time-oauth-code.table.ts │ │ └── user.table.ts │ ├── types/ │ │ └── oauth.types.ts │ ├── utils/ │ │ ├── api-error.ts │ │ ├── utils.ts │ │ └── zod.ts │ └── validations/ │ ├── auth.validation.ts │ ├── custom.refine.validation.ts │ ├── custom.transform.validation.ts │ ├── custom.type.validation.ts │ └── user.validation.ts ├── tests/ │ ├── cloudflare-test.d.ts │ ├── fixtures/ │ │ ├── authorisations.fixture.ts │ │ ├── token.fixture.ts │ │ └── user.fixture.ts │ ├── integration/ │ │ ├── auth/ │ │ │ ├── auth.test.ts │ │ │ └── oauth/ │ │ │ ├── apple.test.ts │ │ │ ├── discord.test.ts │ │ │ ├── facebook.test.ts │ │ │ ├── github.test.ts │ │ │ ├── google.test.ts │ │ │ └── spotify.test.ts │ │ ├── index.test.ts │ │ ├── rate-limiter.test.ts │ │ └── user.test.ts │ ├── mocks/ │ │ └── awsClientStub/ │ │ ├── aws-client-stub.ts │ │ ├── expect-mock.ts │ │ ├── index.ts │ │ └── mock-client.ts │ ├── tsconfig.json │ ├── utils/ │ │ ├── clear-db-tables.ts │ │ └── test-request.ts │ └── vitest.d.ts ├── tsconfig.json ├── vitest.config.ts └── wrangler.toml.example
SYMBOL INDEX (116 symbols across 42 files)
FILE: bindings.d.ts
type Environment (line 5) | type Environment = {
FILE: migrations/01_initial.ts
function up (line 4) | async function up(db: Kysely<Database>) {
function down (line 56) | async function down(db: Kysely<Database>) {
FILE: scripts/migrate.ts
type Database (line 20) | interface Database {
function migrateToLatest (line 41) | async function migrateToLatest() {
function migrateDown (line 60) | async function migrateDown() {
function migrateNone (line 79) | async function migrateNone() {
FILE: src/config/config.ts
type EnvVarsSchemaType (line 43) | type EnvVarsSchemaType = z.infer<typeof envVarsSchema>
type Config (line 45) | interface Config {
FILE: src/config/database.ts
type Database (line 10) | interface Database {
FILE: src/config/roles.ts
type Permission (line 8) | type Permission = (typeof roleRights)[keyof typeof roleRights][number]
type Role (line 9) | type Role = keyof typeof roleRights
FILE: src/config/tokens.ts
type TokenType (line 8) | type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes]
FILE: src/controllers/auth/oauth/apple.controller.ts
type AppleJWT (line 22) | type AppleJWT = {
FILE: src/controllers/auth/oauth/oauth.controller.ts
type State (line 15) | type State = {
FILE: src/durable-objects/rate-limiter.do.ts
type Config (line 8) | interface Config {
class RateLimiter (line 22) | class RateLimiter {
method constructor (line 27) | constructor(state: DurableObjectState, env: Environment['Bindings']) {
method alarm (line 67) | async alarm() {
method setAlarm (line 79) | async setAlarm() {
method getConfig (line 86) | async getConfig(c: Context) {
method incrementRequestCount (line 92) | async incrementRequestCount(key: string) {
method getRequestCount (line 97) | async getRequestCount(key: string): Promise<number> {
method nowUnix (line 101) | nowUnix() {
method calculateRate (line 105) | async calculateRate(config: Config) {
method isRateLimited (line 121) | isRateLimited(rate: number, limit: number) {
method getHeaders (line 125) | getHeaders(blocked: boolean, config: Config) {
method expirySeconds (line 138) | expirySeconds(config: Config) {
method retryAfter (line 145) | retryAfter(expires: number) {
method fetch (line 149) | async fetch(request: Request): Promise<Response> {
FILE: src/models/base.model.ts
method toJSON (line 4) | toJSON() {
FILE: src/models/oauth/apple-user.model.ts
class AppleUser (line 5) | class AppleUser extends OAuthUserModel {
method constructor (line 6) | constructor(user: AppleUserType) {
FILE: src/models/oauth/discord-user.model.ts
class DiscordUser (line 5) | class DiscordUser extends OAuthUserModel {
method constructor (line 6) | constructor(user: DiscordUserType) {
FILE: src/models/oauth/facebook-user.model.ts
class FacebookUser (line 5) | class FacebookUser extends OAuthUserModel {
method constructor (line 6) | constructor(user: FacebookUserType) {
FILE: src/models/oauth/github-user.model.ts
class GithubUser (line 5) | class GithubUser extends OAuthUserModel {
method constructor (line 6) | constructor(user: GithubUserType) {
FILE: src/models/oauth/google-user.model.ts
class GoogleUser (line 5) | class GoogleUser extends OAuthUserModel {
method constructor (line 6) | constructor(user: GoogleUserType) {
FILE: src/models/oauth/oauth-base.model.ts
class OAuthUserModel (line 4) | class OAuthUserModel extends BaseModel implements OAuthUserType {
method constructor (line 12) | constructor(user: OAuthUserType) {
FILE: src/models/oauth/spotify-user.model.ts
class SpotifyUser (line 5) | class SpotifyUser extends OAuthUserModel {
method constructor (line 6) | constructor(user: SpotifyUserType) {
FILE: src/models/one-time-oauth-code.ts
class OneTimeOauthCode (line 5) | class OneTimeOauthCode extends BaseModel implements Selectable<OneTimeOa...
method constructor (line 18) | constructor(oneTimeCode: Selectable<OneTimeOauthCodeTable>) {
FILE: src/models/token.model.ts
type TokenResponse (line 1) | interface TokenResponse {
FILE: src/models/user.model.ts
class User (line 7) | class User extends BaseModel implements Selectable<UserTable> {
method constructor (line 17) | constructor(user: Selectable<UserTable>) {
FILE: src/services/email.service.ts
type EmailData (line 6) | interface EmailData {
FILE: src/services/oauth/apple.service.ts
type AppleResponse (line 4) | type AppleResponse = {
FILE: src/services/oauth/facebook.service.ts
type Options (line 5) | type Options = {
FILE: src/services/oauth/github.service.ts
constant DEFAULT_SCOPE (line 5) | const DEFAULT_SCOPE = ['read:user', 'user:email']
constant DEFAULT_ALLOW_SIGNUP (line 6) | const DEFAULT_ALLOW_SIGNUP = true
type Options (line 8) | type Options = {
type Params (line 16) | type Params = {
FILE: src/services/oauth/spotify.service.ts
type Options (line 5) | type Options = {
FILE: src/services/user.service.ts
type getUsersFilter (line 13) | interface getUsersFilter {
type getUsersOptions (line 17) | interface getUsersOptions {
FILE: src/tables/oauth.table.ts
type AuthProviderTable (line 1) | interface AuthProviderTable {
FILE: src/tables/one-time-oauth-code.table.ts
type OneTimeOauthCodeTable (line 3) | interface OneTimeOauthCodeTable {
FILE: src/tables/user.table.ts
type UserTable (line 3) | interface UserTable {
FILE: src/types/oauth.types.ts
type AuthProviderType (line 9) | type AuthProviderType = (typeof authProviders)[keyof typeof authProviders]
type OAuthUserType (line 11) | interface OAuthUserType {
type AppleUserType (line 18) | interface AppleUserType {
type DiscordUserType (line 24) | interface DiscordUserType {
type FacebookUserType (line 30) | interface FacebookUserType {
type GithubUserType (line 37) | interface GithubUserType {
type GoogleUserType (line 43) | interface GoogleUserType {
type SpotifyUserType (line 49) | interface SpotifyUserType {
type OauthUserTypes (line 55) | interface OauthUserTypes {
type ProviderUserMapping (line 64) | type ProviderUserMapping = {
FILE: src/utils/api-error.ts
class ApiError (line 1) | class ApiError extends Error {
method constructor (line 5) | constructor(statusCode: number, message: string, isOperational = true) {
FILE: src/validations/auth.validation.ts
type Register (line 11) | type Register = z.infer<typeof register>
FILE: src/validations/user.validation.ts
type CreateUser (line 17) | type CreateUser = z.infer<typeof createUser>
type UpdateUser (line 41) | type UpdateUser =
FILE: tests/cloudflare-test.d.ts
type ProvidedEnv (line 3) | type ProvidedEnv = Environment
FILE: tests/fixtures/token.fixture.ts
type TokenResponse (line 7) | interface TokenResponse {
FILE: tests/fixtures/user.fixture.ts
type MockUser (line 13) | type MockUser = Insertable<UserTable>
type UserResponse (line 15) | interface UserResponse {
FILE: tests/mocks/awsClientStub/aws-client-stub.ts
type AwsClientBehavior (line 5) | type AwsClientBehavior<TClient> =
type Behavior (line 10) | interface Behavior<
type AwsClientStub (line 37) | type AwsClientStub<TClient> =
type MockCall (line 42) | type MockCall<In extends unknown[], Out> = {
type MockResult (line 47) | type MockResult<T> =
type Inputs (line 57) | type Inputs<TInput extends object, TOutput extends MetadataBearer, TConf...
type Output (line 60) | type Output<TInput extends object, TOutput extends MetadataBearer, TConf...
class AwsStub (line 72) | class AwsStub<TInput extends object, TOutput extends MetadataBearer, TCo...
method constructor (line 83) | constructor(
method clientName (line 94) | clientName(): string {
method reset (line 101) | reset(): AwsStub<TInput, TOutput, TConfiguration> {
method restore (line 113) | restore(): void {
method calls (line 120) | calls(): MockCall<
method call (line 139) | call(
method on (line 161) | on<TCmdInput extends TInput, TCmdOutput extends TOutput>(
class CommandBehavior (line 178) | class CommandBehavior<
method constructor (line 184) | constructor(
method resolves (line 204) | resolves(
method rejects (line 233) | rejects(error?: string | Error | AwsError): AwsStub<TInput, TOutput, T...
type AwsCommand (line 239) | type AwsCommand<
type CommandResponse (line 245) | type CommandResponse<TOutput> = Partial<TOutput> | PromiseLike<Partial<T...
type AwsError (line 247) | interface AwsError extends Partial<Error>, Partial<MetadataBearer> {
FILE: tests/mocks/awsClientStub/expect-mock.ts
function toHaveReceivedCommandTimes (line 4) | function toHaveReceivedCommandTimes(mock: AwsStub<any, any, any>, comman...
type CustomMatchers (line 20) | interface CustomMatchers<R = unknown> {
type Assertion (line 27) | interface Assertion<T = any> extends CustomMatchers<T> {}
type AsymmetricMatchersContaining (line 29) | interface AsymmetricMatchersContaining extends CustomMatchers {}
FILE: tests/mocks/awsClientStub/mock-client.ts
type ClassType (line 27) | type ClassType<T> = {
type InstanceOrClassType (line 31) | type InstanceOrClassType<T> = T | ClassType<T>
FILE: tests/utils/test-request.ts
class Context (line 7) | class Context implements ExecutionContext {
method passThroughOnException (line 8) | passThroughOnException(): void {
method abort (line 11) | abort(): void {}
method waitUntil (line 13) | async waitUntil(promise: Promise<any>): Promise<void> {
FILE: tests/vitest.d.ts
type Assertion (line 7) | interface Assertion<T = any> extends CustomMatcher<T> {}
type AsymmetricMatchersContaining (line 8) | interface AsymmetricMatchersContaining extends CustomMatcher {}
Condensed preview — 99 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (369K chars).
[
{
"path": ".dev.vars.example",
"chars": 2235,
"preview": "# This file is used to set secrets for running a local dev server via npm run dev as well as tests\n# Use a valid fake PK"
},
{
"path": ".env.example",
"chars": 288,
"preview": "# Please note that this file is only used for running migrations for a development database\n# The rest of the variables "
},
{
"path": ".env.test.example",
"chars": 1478,
"preview": "# Please note that aws credentials and oauth credentials don't have to work only planetscale\n# The apple oauth private k"
},
{
"path": ".eslintignore",
"chars": 18,
"preview": "node_modules\ndist\n"
},
{
"path": ".gitignore",
"chars": 151,
"preview": "/target\n/dist\n**/*.rs.bk\npkg/\nwasm-pack.log\nworker/\nnode_modules/\n.vscode\n.env*\n!*.example\n.mf\nwrangler.toml\ncoverage\n.v"
},
{
"path": ".husky/pre-commit",
"chars": 13,
"preview": "npm run lint\n"
},
{
"path": ".husky/pre-push",
"chars": 23,
"preview": "npm run tests:coverage\n"
},
{
"path": ".prettierignore",
"chars": 18,
"preview": "node_modules\ndist\n"
},
{
"path": ".prettierrc",
"chars": 134,
"preview": "{\n \"singleQuote\": true,\n \"semi\": false,\n \"trailingComma\": \"none\",\n \"tabWidth\": 2,\n \"printWidth\": 100,\n \"bracketSpa"
},
{
"path": "LICENSE",
"chars": 1076,
"preview": "MIT License\n\nCopyright (c) 2022 Ben Louis Armstrong\n\nPermission is hereby granted, free of charge, to any person obtaini"
},
{
"path": "README.md",
"chars": 8010,
"preview": "# RESTful API Cloudflare Workers Boilerplate\nA boilerplate/starter project for quickly building RESTful APIs using\n[Clou"
},
{
"path": "TODO.md",
"chars": 214,
"preview": "### Todo\n\n- [ ] Flesh out README\n- [ ] Oauth\n - [ ] Add support for Twitter\n- [ ] API docs\n- [ ] Fix all types\n- [ ] CI"
},
{
"path": "bin/createApp.js",
"chars": 2362,
"preview": "#!/usr/bin/env node\n\n/* eslint no-console: \"off\" */\nimport { exec as child_exec } from 'child_process'\nimport fs from 'f"
},
{
"path": "bindings.d.ts",
"chars": 1474,
"preview": "import type { JwtPayload } from '@tsndr/cloudflare-worker-jwt'\nimport type { Toucan } from 'toucan-js'\nimport type { Rat"
},
{
"path": "build.js",
"chars": 349,
"preview": "import { build } from 'esbuild'\n\ntry {\n await build({\n entryPoints: ['./src/index.ts'],\n bundle: true,\n "
},
{
"path": "eslint.config.js",
"chars": 2042,
"preview": "import eslint from '@eslint/js'\nimport importx from 'eslint-plugin-import-x'\nimport eslintPluginPrettierRecommended from"
},
{
"path": "jest.config.js",
"chars": 722,
"preview": "export default {\n preset: 'ts-jest/presets/default-esm',\n extensionsToTreatAsEsm: ['.ts'],\n clearMocks: true,\n globa"
},
{
"path": "migrations/01_initial.ts",
"chars": 2699,
"preview": "import { Kysely, sql } from 'kysely'\nimport { Database } from '../src/config/database'\n\nexport async function up(db: Kys"
},
{
"path": "package.json",
"chars": 3055,
"preview": "{\n \"name\": \"create-cf-planetscale-app\",\n \"version\": \"3.0.0\",\n \"description\": \"Create a Cloudflare workers app for bui"
},
{
"path": "scripts/migrate.ts",
"chars": 2667,
"preview": "/* eslint no-console: \"off\" */\nimport { promises as fs } from 'fs'\nimport * as path from 'path'\nimport { fileURLToPath }"
},
{
"path": "src/config/authProviders.ts",
"chars": 167,
"preview": "export const authProviders = {\n GITHUB: 'github',\n SPOTIFY: 'spotify',\n DISCORD: 'discord',\n GOOGLE: 'google',\n FAC"
},
{
"path": "src/config/config.ts",
"chars": 5745,
"preview": "import httpStatus from 'http-status'\nimport { ZodError, z } from 'zod'\nimport { Environment } from '../../bindings'\nimpo"
},
{
"path": "src/config/database.ts",
"chars": 1119,
"preview": "import { Kysely } from 'kysely'\nimport { PlanetScaleDialect } from 'kysely-planetscale'\nimport { AuthProviderTable } fro"
},
{
"path": "src/config/roles.ts",
"chars": 266,
"preview": "export const roleRights = {\n user: [],\n admin: ['getUsers', 'manageUsers']\n} as const\n\nexport const roles = Object.key"
},
{
"path": "src/config/tokens.ts",
"chars": 216,
"preview": "export const tokenTypes = {\n ACCESS: 'access',\n REFRESH: 'refresh',\n RESET_PASSWORD: 'resetPassword',\n VERIFY_EMAIL:"
},
{
"path": "src/controllers/auth/auth.controller.ts",
"chars": 4323,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../../bindings'\nimpo"
},
{
"path": "src/controllers/auth/oauth/apple.controller.ts",
"chars": 4681,
"preview": "// TODO: Handle users using private email relay\n// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_"
},
{
"path": "src/controllers/auth/oauth/discord.controller.ts",
"chars": 2570,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { discord } from 'worker-auth-providers'\nimpo"
},
{
"path": "src/controllers/auth/oauth/facebook.controller.ts",
"chars": 2442,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { facebook } from 'worker-auth-providers'\nimp"
},
{
"path": "src/controllers/auth/oauth/github.controller.ts",
"chars": 2372,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { github } from 'worker-auth-providers'\nimpor"
},
{
"path": "src/controllers/auth/oauth/google.controller.ts",
"chars": 2518,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { google } from 'worker-auth-providers'\nimpor"
},
{
"path": "src/controllers/auth/oauth/oauth.controller.ts",
"chars": 4402,
"preview": "import { Context, Handler } from 'hono'\nimport type { StatusCode } from 'hono/utils/http-status'\nimport httpStatus from "
},
{
"path": "src/controllers/auth/oauth/spotify.controller.ts",
"chars": 2623,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { spotify } from 'worker-auth-providers'\nimpo"
},
{
"path": "src/controllers/user.controller.ts",
"chars": 2182,
"preview": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../bindings'\nimport "
},
{
"path": "src/durable-objects/rate-limiter.do.ts",
"chars": 4630,
"preview": "import dayjs from 'dayjs'\nimport { Context, Hono } from 'hono'\nimport httpStatus from 'http-status'\nimport { z, ZodError"
},
{
"path": "src/factories/oauth.factory.ts",
"chars": 641,
"preview": "import { AppleUser } from '../models/oauth/apple-user.model'\nimport { DiscordUser } from '../models/oauth/discord-user.m"
},
{
"path": "src/index.ts",
"chars": 670,
"preview": "import { sentry } from '@hono/sentry'\nimport { Hono } from 'hono'\nimport { cors } from 'hono/cors'\nimport httpStatus fro"
},
{
"path": "src/middlewares/auth.ts",
"chars": 2364,
"preview": "import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt'\nimport { MiddlewareHandler } from 'hono'\nimport httpStatu"
},
{
"path": "src/middlewares/error.ts",
"chars": 2358,
"preview": "import { getSentry } from '@hono/sentry'\nimport type { ErrorHandler } from 'hono'\nimport { StatusCode } from 'hono/utils"
},
{
"path": "src/middlewares/rate-limiter.ts",
"chars": 2445,
"preview": "import dayjs from 'dayjs'\nimport { Context, MiddlewareHandler } from 'hono'\nimport httpStatus from 'http-status'\nimport "
},
{
"path": "src/models/base.model.ts",
"chars": 484,
"preview": "export abstract class BaseModel {\n abstract private_fields: string[]\n\n toJSON() {\n const properties = Object.getOwn"
},
{
"path": "src/models/oauth/apple-user.model.ts",
"chars": 483,
"preview": "import { authProviders } from '../../config/authProviders'\nimport { AppleUserType } from '../../types/oauth.types'\nimpor"
},
{
"path": "src/models/oauth/discord-user.model.ts",
"chars": 400,
"preview": "import { authProviders } from '../../config/authProviders'\nimport { DiscordUserType } from '../../types/oauth.types'\nimp"
},
{
"path": "src/models/oauth/facebook-user.model.ts",
"chars": 429,
"preview": "import { authProviders } from '../../config/authProviders'\nimport { FacebookUserType } from '../../types/oauth.types'\nim"
},
{
"path": "src/models/oauth/github-user.model.ts",
"chars": 403,
"preview": "import { authProviders } from '../../config/authProviders'\nimport { GithubUserType } from '../../types/oauth.types'\nimpo"
},
{
"path": "src/models/oauth/google-user.model.ts",
"chars": 392,
"preview": "import { authProviders } from '../../config/authProviders'\nimport { GoogleUserType } from '../../types/oauth.types'\nimpo"
},
{
"path": "src/models/oauth/oauth-base.model.ts",
"chars": 492,
"preview": "import { AuthProviderType, OAuthUserType } from '../../types/oauth.types'\nimport { BaseModel } from '../base.model'\n\nexp"
},
{
"path": "src/models/oauth/spotify-user.model.ts",
"chars": 404,
"preview": "import { authProviders } from '../../config/authProviders'\nimport { SpotifyUserType } from '../../types/oauth.types'\nimp"
},
{
"path": "src/models/one-time-oauth-code.ts",
"chars": 1034,
"preview": "import { Selectable } from 'kysely'\nimport { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table'\nimport {"
},
{
"path": "src/models/token.model.ts",
"chars": 140,
"preview": "export interface TokenResponse {\n access: {\n token: string\n expires: Date\n }\n refresh: {\n token: string\n "
},
{
"path": "src/models/user.model.ts",
"chars": 933,
"preview": "import bcrypt from 'bcryptjs'\nimport { Selectable } from 'kysely'\nimport { Role } from '../config/roles'\nimport { UserTa"
},
{
"path": "src/routes/auth.route.ts",
"chars": 3306,
"preview": "import { Hono } from 'hono'\nimport { Environment } from '../../bindings'\nimport * as authController from '../controllers"
},
{
"path": "src/routes/index.ts",
"chars": 282,
"preview": "import { route as authRoute } from './auth.route'\nimport { route as userRoute } from './user.route'\n\nconst base_path = '"
},
{
"path": "src/routes/user.route.ts",
"chars": 560,
"preview": "import { Hono } from 'hono'\nimport { Environment } from '../../bindings'\nimport * as userController from '../controllers"
},
{
"path": "src/services/auth.service.ts",
"chars": 5907,
"preview": "import httpStatus from 'http-status'\nimport { Config } from '../config/config'\nimport { getDBClient } from '../config/da"
},
{
"path": "src/services/email.service.ts",
"chars": 1872,
"preview": "import { SESClient, SendEmailCommand, Message } from '@aws-sdk/client-ses'\nimport { Config } from '../config/config'\n\nle"
},
{
"path": "src/services/oauth/apple.service.ts",
"chars": 884,
"preview": "import httpStatus from 'http-status'\nimport { ApiError } from '../../utils/api-error'\n\ntype AppleResponse = {\n error?: "
},
{
"path": "src/services/oauth/facebook.service.ts",
"chars": 941,
"preview": "import httpStatus from 'http-status'\nimport * as queryString from 'query-string'\nimport { ApiError } from '../../utils/a"
},
{
"path": "src/services/oauth/github.service.ts",
"chars": 1131,
"preview": "import httpStatus from 'http-status'\nimport * as queryString from 'query-string'\nimport { ApiError } from '../../utils/a"
},
{
"path": "src/services/oauth/spotify.service.ts",
"chars": 908,
"preview": "import httpStatus from 'http-status'\nimport * as queryString from 'query-string'\nimport { ApiError } from '../../utils/a"
},
{
"path": "src/services/token.service.ts",
"chars": 4658,
"preview": "import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt'\nimport dayjs, { Dayjs } from 'dayjs'\nimport httpStatus fr"
},
{
"path": "src/services/user.service.ts",
"chars": 5897,
"preview": "import httpStatus from 'http-status'\nimport { UpdateResult } from 'kysely'\nimport { Config } from '../config/config'\nimp"
},
{
"path": "src/tables/oauth.table.ts",
"chars": 108,
"preview": "export interface AuthProviderTable {\n provider_user_id: string\n provider_type: string\n user_id: string\n}\n"
},
{
"path": "src/tables/one-time-oauth-code.table.ts",
"chars": 303,
"preview": "import { Generated } from 'kysely'\n\nexport interface OneTimeOauthCodeTable {\n code: string\n user_id: string\n access_t"
},
{
"path": "src/tables/user.table.ts",
"chars": 277,
"preview": "import { Role } from '../config/roles'\n\nexport interface UserTable {\n id: string\n name: string | null // null if not a"
},
{
"path": "src/types/oauth.types.ts",
"chars": 1553,
"preview": "import { authProviders } from '../config/authProviders'\nimport { AppleUser } from '../models/oauth/apple-user.model'\nimp"
},
{
"path": "src/utils/api-error.ts",
"chars": 257,
"preview": "export class ApiError extends Error {\n statusCode: number\n isOperational: boolean\n\n constructor(statusCode: number, m"
},
{
"path": "src/utils/utils.ts",
"chars": 87,
"preview": "import { nanoid } from 'nanoid'\n\nexport const generateId = () => {\n return nanoid()\n}\n"
},
{
"path": "src/utils/zod.ts",
"chars": 187,
"preview": "import { ZodError } from 'zod'\nimport { fromError } from 'zod-validation-error'\n\nexport const generateZodErrorMessage = "
},
{
"path": "src/validations/auth.validation.ts",
"chars": 1513,
"preview": "import { z } from 'zod'\nimport { password } from './custom.refine.validation'\nimport { hashPassword } from './custom.tra"
},
{
"path": "src/validations/custom.refine.validation.ts",
"chars": 475,
"preview": "import { z } from 'zod'\n\nexport const password = async (value: string, ctx: z.RefinementCtx): Promise<void> => {\n if (v"
},
{
"path": "src/validations/custom.transform.validation.ts",
"chars": 182,
"preview": "import bcrypt from 'bcryptjs'\n\nexport const hashPassword = async (value: string): Promise<string> => {\n const hashedPas"
},
{
"path": "src/validations/custom.type.validation.ts",
"chars": 101,
"preview": "import { z } from 'zod'\n\nexport const roleZodType = z.union([z.literal('admin'), z.literal('user')])\n"
},
{
"path": "src/validations/user.validation.ts",
"chars": 1374,
"preview": "import { z } from 'zod'\nimport { password } from './custom.refine.validation'\nimport { hashPassword } from './custom.tra"
},
{
"path": "tests/cloudflare-test.d.ts",
"chars": 114,
"preview": "declare module 'cloudflare:test' {\n import { Environment } from '../bindings'\n type ProvidedEnv = Environment\n}\n"
},
{
"path": "tests/fixtures/authorisations.fixture.ts",
"chars": 1667,
"preview": "import { faker } from '@faker-js/faker'\nimport { Insertable } from 'kysely'\nimport { authProviders } from '../../src/con"
},
{
"path": "tests/fixtures/token.fixture.ts",
"chars": 799,
"preview": "import dayjs from 'dayjs'\nimport { Config } from '../../src/config/config'\nimport { Role } from '../../src/config/roles'"
},
{
"path": "tests/fixtures/user.fixture.ts",
"chars": 1557,
"preview": "import { faker } from '@faker-js/faker'\nimport bcrypt from 'bcryptjs'\nimport { Insertable } from 'kysely'\nimport { Confi"
},
{
"path": "tests/integration/auth/auth.test.ts",
"chars": 29568,
"preview": "// TODO: Add SES mock client back. It's not working with vitest\n// import { mockClient } from 'aws-sdk-client-mock'\nimpo"
},
{
"path": "tests/integration/auth/oauth/apple.test.ts",
"chars": 23722,
"preview": "import { faker } from '@faker-js/faker'\nimport jwt from '@tsndr/cloudflare-worker-jwt'\nimport { env, fetchMock } from 'c"
},
{
"path": "tests/integration/auth/oauth/discord.test.ts",
"chars": 24408,
"preview": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-st"
},
{
"path": "tests/integration/auth/oauth/facebook.test.ts",
"chars": 24101,
"preview": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-st"
},
{
"path": "tests/integration/auth/oauth/github.test.ts",
"chars": 23211,
"preview": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-st"
},
{
"path": "tests/integration/auth/oauth/google.test.ts",
"chars": 23640,
"preview": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-st"
},
{
"path": "tests/integration/auth/oauth/spotify.test.ts",
"chars": 24701,
"preview": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-st"
},
{
"path": "tests/integration/index.test.ts",
"chars": 361,
"preview": "import httpStatus from 'http-status'\nimport { test, describe, expect } from 'vitest'\nimport { request } from '../utils/t"
},
{
"path": "tests/integration/rate-limiter.test.ts",
"chars": 17269,
"preview": "import { env, runInDurableObject, runDurableObjectAlarm } from 'cloudflare:test'\nimport dayjs from 'dayjs'\nimport isSame"
},
{
"path": "tests/integration/user.test.ts",
"chars": 26674,
"preview": "import { faker } from '@faker-js/faker'\nimport { env } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimpor"
},
{
"path": "tests/mocks/awsClientStub/aws-client-stub.ts",
"chars": 7517,
"preview": "import { Client, Command, MetadataBearer } from '@smithy/types'\nimport { MockInstance, vi, Mock } from 'vitest'\nimport {"
},
{
"path": "tests/mocks/awsClientStub/expect-mock.ts",
"chars": 874,
"preview": "import { AwsStub } from './aws-client-stub'\n\n// eslint-disable-next-line\nexport function toHaveReceivedCommandTimes(mock"
},
{
"path": "tests/mocks/awsClientStub/index.ts",
"chars": 94,
"preview": "export * from './mock-client'\nexport * from './aws-client-stub'\nexport * from './expect-mock'\n"
},
{
"path": "tests/mocks/awsClientStub/mock-client.ts",
"chars": 1376,
"preview": "import { Client, MetadataBearer } from '@smithy/types'\nimport { vi } from 'vitest'\nimport { AwsClientStub, AwsStub } fro"
},
{
"path": "tests/tsconfig.json",
"chars": 346,
"preview": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": {\n \"moduleResolution\": \"bundler\",\n \"types\": [\n \"@type"
},
{
"path": "tests/utils/clear-db-tables.ts",
"chars": 448,
"preview": "import { beforeEach } from 'vitest'\nimport { Config } from '../../src/config/config'\nimport { getDBClient, Database } fr"
},
{
"path": "tests/utils/test-request.ts",
"chars": 662,
"preview": "import { env } from 'cloudflare:test'\nimport app from '../../src'\nimport '../../src/routes'\n\nconst devUrl = 'http://loca"
},
{
"path": "tests/vitest.d.ts",
"chars": 355,
"preview": "/* eslint-disable @typescript-eslint/no-empty-object-type */\nimport 'vitest'\nimport { CustomMatcher } from 'aws-sdk-clie"
},
{
"path": "tsconfig.json",
"chars": 502,
"preview": "{\n \"compilerOptions\": {\n \"allowJs\": true,\n \"target\": \"esnext\",\n \"lib\": [\"esnext\"],\n \"moduleResolution\": \"bu"
},
{
"path": "vitest.config.ts",
"chars": 312,
"preview": "import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'\n\nexport default defineWorkersConfig({\n tes"
},
{
"path": "wrangler.toml.example",
"chars": 2649,
"preview": "name = 'cf-workers-hono-planetscale-app'\nmain = 'dist/index.mjs'\n\nworkers_dev = true\ncompatibility_date = '2024-08-23'\nc"
}
]
About this extraction
This page contains the full source code of the OultimoCoder/cloudflare-planetscale-hono-boilerplate GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 99 files (344.8 KB), approximately 86.9k tokens, and a symbol index with 116 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.