main 3ceabdc9c29a cached
99 files
344.8 KB
86.9k tokens
116 symbols
1 requests
Download .txt
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
Download .txt
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
Download .txt
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.

Copied to clipboard!