Refactor to how v2 Tofustack works.

This commit is contained in:
Bradley Shellnut 2024-12-01 15:34:04 -08:00
parent 85050d0ec4
commit 20e37c4f18
117 changed files with 3611 additions and 3276 deletions

View file

@ -10,6 +10,10 @@ DATABASE_PASSWORD='postgres'
DATABASE_HOST='localhost'
DATABASE_PORT=5432
DATABASE_DB='postgres'
PORT=5173
ENV=dev
SIGNING_SECRET=""
ENCRYPTION_KEY=""
REDIS_URL='redis://127.0.0.1:6379/0'

View file

@ -1,143 +1,142 @@
{
"name": "boredgame",
"version": "0.0.5",
"private": "true",
"scripts": {
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/lib/server/api/databases/postgres/migrate.ts",
"db:seed": "tsx src/lib/server/api/databases/postgres/seed.ts",
"db:studio": "drizzle-kit studio --verbose",
"dev": "NODE_OPTIONS=\"--inspect\" vite dev --host",
"build": "vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"test:e2e": "playwright test",
"test:ui": "svelte-kit sync && playwright test --ui",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"initialize": "pnpm install && docker compose up --no-recreate -d && pnpm db:migrate && pnpm db:seed",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"site:update": "pnpm update -i -L",
"test:unit": "vitest"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@faker-js/faker": "^8.4.1",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.49.0",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/enhanced-img": "^0.3.10",
"@sveltejs/kit": "^2.8.2",
"@sveltejs/vite-plugin-svelte": "4.0.0-next.7",
"@types/cookie": "^0.6.0",
"@types/node": "^20.17.7",
"@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"arctic": "^1.9.2",
"autoprefixer": "^10.4.20",
"bits-ui": "^0.21.16",
"drizzle-kit": "^0.27.2",
"formsnap": "^1.0.1",
"just-clone": "^6.2.0",
"just-debounce-it": "^3.2.0",
"lucia": "3.2.0",
"lucide-svelte": "^0.408.0",
"mode-watcher": "^0.4.1",
"nodemailer": "^6.9.16",
"postcss": "^8.4.49",
"postcss-import": "^16.1.0",
"postcss-load-config": "^5.1.0",
"postcss-preset-env": "^9.6.0",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "5.0.0-next.175",
"svelte-check": "^3.8.6",
"svelte-headless-table": "^0.18.3",
"svelte-meta-tags": "^3.1.4",
"svelte-preprocess": "^6.0.3",
"svelte-sequential-preprocessor": "^2.0.2",
"svelte-sonner": "^0.3.28",
"sveltekit-flash-message": "^2.4.4",
"sveltekit-superforms": "^2.20.1",
"tailwindcss": "^3.4.15",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^1.6.0",
"zod": "^3.23.8"
},
"type": "module",
"dependencies": {
"@fontsource/fira-mono": "^5.1.0",
"@hono/swagger-ui": "^0.4.1",
"@hono/zod-openapi": "^0.15.3",
"@hono/zod-validator": "^0.2.2",
"@iconify-icons/line-md": "^1.2.30",
"@iconify-icons/mdi": "^1.2.48",
"@inlang/paraglide-sveltekit": "^0.11.1",
"@internationalized/date": "^3.6.0",
"@lucia-auth/adapter-drizzle": "^1.1.0",
"@lukeed/uuid": "^2.0.1",
"@needle-di/core": "^0.8.4",
"@neondatabase/serverless": "^0.9.5",
"@node-rs/argon2": "^1.8.3",
"@oslojs/binary": "^1.0.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@oslojs/jwt": "^0.2.0",
"@oslojs/oauth2": "^0.5.0",
"@oslojs/otp": "^1.0.0",
"@oslojs/webauthn": "^1.0.0",
"@paralleldrive/cuid2": "^2.2.2",
"@scalar/hono-api-reference": "^0.5.161",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/adapter-vercel": "^5.4.8",
"@types/feather-icons": "^4.29.4",
"boardgamegeekclient": "^1.9.1",
"bullmq": "^5.29.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cookie": "^1.0.2",
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.7",
"drizzle-orm": "^0.36.4",
"drizzle-zod": "^0.5.1",
"feather-icons": "^4.29.2",
"handlebars": "^4.7.8",
"hono": "^4.6.11",
"hono-pino": "^0.7.0",
"hono-rate-limiter": "^0.4.0",
"hono-zod-openapi": "^0.5.0",
"html-entities": "^2.5.2",
"iconify-icon": "^2.1.0",
"ioredis": "^5.4.1",
"just-capitalize": "^3.2.0",
"just-kebab-case": "^4.2.0",
"loader": "^2.1.1",
"nanoid": "^5.0.8",
"open-props": "^1.7.7",
"oslo": "^1.2.1",
"pg": "^8.13.1",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"postgres": "^3.4.5",
"qrcode": "^1.5.4",
"radix-svelte": "^0.9.0",
"rate-limit-redis": "^4.2.0",
"reflect-metadata": "^0.2.2",
"stoker": "^1.3.0",
"svelte-lazy-loader": "^1.0.0",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "^0.2.1",
"tailwindcss-animate": "^1.0.7",
"tsyringe": "^4.8.0",
"zod-to-json-schema": "^3.23.5"
}
"name": "boredgame",
"version": "0.0.5",
"private": "true",
"scripts": {
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/lib/server/api/databases/postgres/migrate.ts",
"db:seed": "tsx src/lib/server/api/databases/postgres/seed.ts",
"db:studio": "drizzle-kit studio --verbose",
"dev": "NODE_OPTIONS=\"--inspect\" vite dev --host",
"build": "vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"test:e2e": "playwright test",
"test:ui": "svelte-kit sync && playwright test --ui",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"initialize": "pnpm install && docker compose up --no-recreate -d && pnpm db:migrate && pnpm db:seed",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"site:update": "pnpm update -i -L",
"test:unit": "vitest"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@faker-js/faker": "^8.4.1",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.49.0",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/enhanced-img": "^0.3.10",
"@sveltejs/kit": "^2.8.5",
"@sveltejs/vite-plugin-svelte": "4.0.0-next.7",
"@types/cookie": "^0.6.0",
"@types/node": "^20.17.8",
"@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"arctic": "^2.3.0",
"autoprefixer": "^10.4.20",
"bits-ui": "^0.21.16",
"drizzle-kit": "^0.27.2",
"formsnap": "^1.0.1",
"just-clone": "^6.2.0",
"just-debounce-it": "^3.2.0",
"lucide-svelte": "^0.408.0",
"mode-watcher": "^0.4.1",
"nodemailer": "^6.9.16",
"postcss": "^8.4.49",
"postcss-import": "^16.1.0",
"postcss-load-config": "^5.1.0",
"postcss-preset-env": "^9.6.0",
"prettier": "^3.4.1",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "5.0.0-next.175",
"svelte-check": "^3.8.6",
"svelte-headless-table": "^0.18.3",
"svelte-meta-tags": "^3.1.4",
"svelte-preprocess": "^6.0.3",
"svelte-sequential-preprocessor": "^2.0.2",
"svelte-sonner": "^0.3.28",
"sveltekit-flash-message": "^2.4.4",
"sveltekit-superforms": "^2.20.1",
"tailwindcss": "^3.4.15",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^1.6.0",
"zod": "^3.23.8"
},
"type": "module",
"dependencies": {
"@fontsource/fira-mono": "^5.1.0",
"@hono/swagger-ui": "^0.4.1",
"@hono/zod-openapi": "^0.15.3",
"@hono/zod-validator": "^0.2.2",
"@iconify-icons/line-md": "^1.2.30",
"@iconify-icons/mdi": "^1.2.48",
"@inlang/paraglide-sveltekit": "^0.11.1",
"@internationalized/date": "^3.6.0",
"@lucia-auth/adapter-drizzle": "^1.1.0",
"@lukeed/uuid": "^2.0.1",
"@needle-di/core": "^0.8.4",
"@neondatabase/serverless": "^0.9.5",
"@node-rs/argon2": "^1.8.3",
"@oslojs/binary": "^1.0.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@oslojs/jwt": "^0.2.0",
"@oslojs/oauth2": "^0.5.0",
"@oslojs/otp": "^1.0.0",
"@oslojs/webauthn": "^1.0.0",
"@paralleldrive/cuid2": "^2.2.2",
"@scalar/hono-api-reference": "^0.5.162",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/adapter-vercel": "^5.4.8",
"@types/feather-icons": "^4.29.4",
"argon2": "^0.41.1",
"boardgamegeekclient": "^1.9.1",
"bullmq": "^5.29.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookie": "^1.0.2",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.7",
"drizzle-orm": "^0.36.4",
"drizzle-zod": "^0.5.1",
"feather-icons": "^4.29.2",
"handlebars": "^4.7.8",
"hono": "^4.6.12",
"hono-pino": "^0.7.0",
"hono-rate-limiter": "^0.4.0",
"hono-zod-openapi": "^0.5.0",
"html-entities": "^2.5.2",
"iconify-icon": "^2.1.0",
"ioredis": "^5.4.1",
"just-capitalize": "^3.2.0",
"just-kebab-case": "^4.2.0",
"loader": "^2.1.1",
"nanoid": "^5.0.9",
"open-props": "^1.7.7",
"pg": "^8.13.1",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"postgres": "^3.4.5",
"qrcode": "^1.5.4",
"radix-svelte": "^0.9.0",
"rate-limit-redis": "^4.2.0",
"reflect-metadata": "^0.2.2",
"stoker": "^1.4.2",
"svelte-lazy-loader": "^1.0.0",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "^0.2.1",
"tailwindcss-animate": "^1.0.7",
"zod-to-json-schema": "^3.23.5"
}
}

View file

@ -13,13 +13,13 @@ importers:
version: 5.1.0
'@hono/swagger-ui':
specifier: ^0.4.1
version: 0.4.1(hono@4.6.11)
version: 0.4.1(hono@4.6.12)
'@hono/zod-openapi':
specifier: ^0.15.3
version: 0.15.3(hono@4.6.11)(zod@3.23.8)
version: 0.15.3(hono@4.6.12)(zod@3.23.8)
'@hono/zod-validator':
specifier: ^0.2.2
version: 0.2.2(hono@4.6.11)(zod@3.23.8)
version: 0.2.2(hono@4.6.12)(zod@3.23.8)
'@iconify-icons/line-md':
specifier: ^1.2.30
version: 1.2.30
@ -28,7 +28,7 @@ importers:
version: 1.2.48
'@inlang/paraglide-sveltekit':
specifier: ^0.11.1
version: 0.11.5(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))
version: 0.11.5(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))
'@internationalized/date':
specifier: ^3.6.0
version: 3.6.0
@ -72,17 +72,20 @@ importers:
specifier: ^2.2.2
version: 2.2.2
'@scalar/hono-api-reference':
specifier: ^0.5.161
version: 0.5.161(hono@4.6.11)
specifier: ^0.5.162
version: 0.5.162(hono@4.6.12)
'@sveltejs/adapter-node':
specifier: ^5.2.9
version: 5.2.9(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))
version: 5.2.9(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))
'@sveltejs/adapter-vercel':
specifier: ^5.4.8
version: 5.4.8(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))
version: 5.4.8(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))
'@types/feather-icons':
specifier: ^4.29.4
version: 4.29.4
argon2:
specifier: ^0.41.1
version: 0.41.1
boardgamegeekclient:
specifier: ^1.9.1
version: 1.9.1
@ -90,14 +93,17 @@ importers:
specifier: ^5.29.1
version: 5.29.1
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
cookie:
specifier: ^1.0.2
version: 1.0.2
dayjs:
specifier: ^1.11.13
version: 1.11.13
dotenv:
specifier: ^16.4.5
version: 16.4.5
@ -117,17 +123,17 @@ importers:
specifier: ^4.7.8
version: 4.7.8
hono:
specifier: ^4.6.11
version: 4.6.11
specifier: ^4.6.12
version: 4.6.12
hono-pino:
specifier: ^0.7.0
version: 0.7.0(hono@4.6.11)(pino@9.5.0)
version: 0.7.0(hono@4.6.12)(pino@9.5.0)
hono-rate-limiter:
specifier: ^0.4.0
version: 0.4.0(hono@4.6.11)
version: 0.4.0(hono@4.6.12)
hono-zod-openapi:
specifier: ^0.5.0
version: 0.5.0(hono@4.6.11)(zod@3.23.8)
version: 0.5.0(hono@4.6.12)(zod@3.23.8)
html-entities:
specifier: ^2.5.2
version: 2.5.2
@ -147,14 +153,11 @@ importers:
specifier: ^2.1.1
version: 2.1.1
nanoid:
specifier: ^5.0.8
version: 5.0.8
specifier: ^5.0.9
version: 5.0.9
open-props:
specifier: ^1.7.7
version: 1.7.7
oslo:
specifier: ^1.2.1
version: 1.2.1
pg:
specifier: ^8.13.1
version: 8.13.1
@ -180,8 +183,8 @@ importers:
specifier: ^0.2.2
version: 0.2.2
stoker:
specifier: ^1.3.0
version: 1.3.0(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.11)(zod@3.23.8))(hono@4.6.11)(openapi3-ts@4.4.0)
specifier: ^1.4.2
version: 1.4.2(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.12)(zod@3.23.8))(hono@4.6.12)(openapi3-ts@4.4.0)
svelte-lazy-loader:
specifier: ^1.0.0
version: 1.0.0
@ -190,13 +193,10 @@ importers:
version: 2.5.5
tailwind-variants:
specifier: ^0.2.1
version: 0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)))
version: 0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)))
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)))
tsyringe:
specifier: ^4.8.0
version: 4.8.0
version: 1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)))
zod-to-json-schema:
specifier: ^3.23.5
version: 3.23.5(zod@3.23.8)
@ -218,22 +218,22 @@ importers:
version: 1.49.0
'@sveltejs/adapter-auto':
specifier: ^3.3.1
version: 3.3.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))
version: 3.3.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))
'@sveltejs/enhanced-img':
specifier: ^0.3.10
version: 0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
version: 0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
'@sveltejs/kit':
specifier: ^2.8.2
version: 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
specifier: ^2.8.5
version: 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
'@sveltejs/vite-plugin-svelte':
specifier: 4.0.0-next.7
version: 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
version: 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
'@types/cookie':
specifier: ^0.6.0
version: 0.6.0
'@types/node':
specifier: ^20.17.7
version: 20.17.7
specifier: ^20.17.8
version: 20.17.8
'@types/pg':
specifier: ^8.11.10
version: 8.11.10
@ -247,8 +247,8 @@ importers:
specifier: ^7.18.0
version: 7.18.0(eslint@8.57.1)(typescript@5.7.2)
arctic:
specifier: ^1.9.2
version: 1.9.2
specifier: ^2.3.0
version: 2.3.0
autoprefixer:
specifier: ^10.4.20
version: 10.4.20(postcss@8.4.49)
@ -260,16 +260,13 @@ importers:
version: 0.27.2
formsnap:
specifier: ^1.0.1
version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2))
version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2))
just-clone:
specifier: ^6.2.0
version: 6.2.0
just-debounce-it:
specifier: ^3.2.0
version: 3.2.0
lucia:
specifier: 3.2.0
version: 3.2.0
lucide-svelte:
specifier: ^0.408.0
version: 0.408.0(svelte@5.0.0-next.175)
@ -292,11 +289,11 @@ importers:
specifier: ^9.6.0
version: 9.6.0(postcss@8.4.49)
prettier:
specifier: ^3.3.3
version: 3.3.3
specifier: ^3.4.1
version: 3.4.1
prettier-plugin-svelte:
specifier: ^3.3.2
version: 3.3.2(prettier@3.3.3)(svelte@5.0.0-next.175)
version: 3.3.2(prettier@3.4.1)(svelte@5.0.0-next.175)
svelte:
specifier: 5.0.0-next.175
version: 5.0.0-next.175
@ -320,16 +317,16 @@ importers:
version: 0.3.28(svelte@5.0.0-next.175)
sveltekit-flash-message:
specifier: ^2.4.4
version: 2.4.4(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)
version: 2.4.4(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)
sveltekit-superforms:
specifier: ^2.20.1
version: 2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)
version: 2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)
tailwindcss:
specifier: ^3.4.15
version: 3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))
version: 3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.17.7)(typescript@5.7.2)
version: 10.9.2(@types/node@20.17.8)(typescript@5.7.2)
tslib:
specifier: ^2.8.1
version: 2.8.1
@ -341,10 +338,10 @@ importers:
version: 5.7.2
vite:
specifier: ^5.4.11
version: 5.4.11(@types/node@20.17.7)
version: 5.4.11(@types/node@20.17.8)
vitest:
specifier: ^1.6.0
version: 1.6.0(@types/node@20.17.7)
version: 1.6.0(@types/node@20.17.8)
zod:
specifier: ^3.23.8
version: 3.23.8
@ -2211,6 +2208,10 @@ packages:
'@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -2347,8 +2348,8 @@ packages:
cpu: [x64]
os: [win32]
'@scalar/hono-api-reference@0.5.161':
resolution: {integrity: sha512-n30VVZrl9z/IcRbyNZZLGx4RMUUH1Zk12ryR1zPG/tdkzNG7ODP6D+rhasn5cSaCg/0BvdCcHEaANEwVJUEjqw==}
'@scalar/hono-api-reference@0.5.162':
resolution: {integrity: sha512-WoC6lLXLYSB6OxDuybtYe+5/EdU0M33DPsknjRsOImp+L6Qn64OZvKwyf7033rEHWvqqpwvEB9r+2zZ4F6KhoQ==}
engines: {node: '>=18'}
peerDependencies:
hono: ^4.0.0
@ -2357,8 +2358,8 @@ packages:
resolution: {integrity: sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw==}
engines: {node: '>=18'}
'@scalar/types@0.0.21':
resolution: {integrity: sha512-HR8KeV5zjdVDQdfs7ONhpIRbAzrMS1KIu2sbNgVXtFSaj+1/6WN5gM/yY1DKoaC3oyvpqwd/HyL9rLTqrOkrRw==}
'@scalar/types@0.0.22':
resolution: {integrity: sha512-+S1flivP58p2uiHM4dU5ZaAb20wbVcP0nV39KWoVjijvHDx1HWtAGg+PaDXRCRj2zM4QzBeg4olaso20Tm26fQ==}
engines: {node: '>=18'}
'@sideway/address@4.1.5':
@ -2400,8 +2401,8 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
vite: '>= 5.0.0'
'@sveltejs/kit@2.8.2':
resolution: {integrity: sha512-c9My0AnojYtaa96XDAcxcMUdMd3iIhWfrj6BLNtOFz55lMtA/Jima54ZLcYcvfMqei3c86fGRXYa2aIHO+vzFg==}
'@sveltejs/kit@2.8.5':
resolution: {integrity: sha512-5ry1jPd4r9knsphDK2eTYUFPhFZMqF0PHFfa8MdMQCqWaKwLSXdFMU/Vevih1I7C1/VNB5MvTuFl1kXu5vx8UA==}
engines: {node: '>=18.13'}
hasBin: true
peerDependencies:
@ -2466,8 +2467,8 @@ packages:
'@types/jsonwebtoken@9.0.7':
resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==}
'@types/node@20.17.7':
resolution: {integrity: sha512-sZXXnpBFMKbao30dUAvzKbdwA2JM1fwUtVEq/kxKuPI5mMwZiRElCpTXb0Biq/LMEVpXDZL5G5V0RPnxKeyaYg==}
'@types/node@20.17.8':
resolution: {integrity: sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==}
'@types/pg@8.11.10':
resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==}
@ -2564,8 +2565,8 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
'@unhead/schema@1.11.11':
resolution: {integrity: sha512-xSGsWHPBYcMV/ckQeImbrVu6ddeRnrdDCgXUKv3xIjGBY+ob/96V80lGX8FKWh8GwdFSwhblISObKlDAt5K9ZQ==}
'@unhead/schema@1.11.13':
resolution: {integrity: sha512-fIpQx6GCpl99l4qJXsPqkXxO7suMccuLADbhaMSkeXnVEi4ZIle+l+Ri0z+GHAEpJj17FMaQdO5n9FMSOMUxkw==}
'@vercel/nft@0.27.4':
resolution: {integrity: sha512-Rioz3LJkEKicKCi9BSyc1RXZ5R6GmXosFMeBSThh6msWSOiArKhb7c75MiWwZEgPL7x0/l3TAfH/l0cxKNuUFA==}
@ -2680,8 +2681,8 @@ packages:
aproba@2.0.0:
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
arctic@1.9.2:
resolution: {integrity: sha512-VTnGpYx+ypboJdNrWnK17WeD7zN/xSCHnpecd5QYsBfVZde/5i+7DJ1wrf/ioSDMiEjagXmyNWAE3V2C9f1hNg==}
arctic@2.3.0:
resolution: {integrity: sha512-ImueY1iKm044nMVxQGsLvzSFLrLsqCIpsvohZprK2l8o3ypXjoSKiMBlxBBdoFpAG0iC78cJ6J/vyLpvdLQlkw==}
are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
@ -2694,6 +2695,10 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argon2@0.41.1:
resolution: {integrity: sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==}
engines: {node: '>=16.17.0'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -2860,8 +2865,8 @@ packages:
class-validator@0.14.1:
resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==}
class-variance-authority@0.7.0:
resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
@ -2876,10 +2881,6 @@ packages:
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.0.0:
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@ -3662,8 +3663,8 @@ packages:
hono: ^4.6.10
zod: ^3.21.4
hono@4.6.11:
resolution: {integrity: sha512-f0LwJQFKdUUrCUAVowxSvNCjyzI7ZLt8XWYU/EApyeq5FfOvHFarBaE5rjU9HTNFk4RI0FkdB2edb3p/7xZjzQ==}
hono@4.6.12:
resolution: {integrity: sha512-eHtf4kSDNw6VVrdbd5IQi16r22m3s7mWPLd7xOMhg1a/Yyb1A0qpUFq8xYMX4FMuDe1nTKeMX5rTx7Nmw+a+Ag==}
engines: {node: '>=16.9.0'}
hookable@5.5.3:
@ -4091,13 +4092,13 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.0.8:
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
nanoid@5.0.9:
resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==}
engines: {node: ^18 || >=20}
hasBin: true
@ -4114,6 +4115,10 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.2.2:
resolution: {integrity: sha512-9emqXAKhVoNrQ792nLI/wpzPpJ/bj/YXxW0CvAau1+RdGBcCRF1Dmz7719zgVsQNrzHl9Tzn3ImZ4qWFarWL0A==}
engines: {node: ^18 || ^20 || >= 21}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@ -4210,9 +4215,6 @@ packages:
oslo@1.2.0:
resolution: {integrity: sha512-OoFX6rDsNcOQVAD2gQD/z03u4vEjWZLzJtwkmgfRF+KpQUXwdgEXErD7zNhyowmHwHefP+PM9Pw13pgpHMRlzw==}
oslo@1.2.1:
resolution: {integrity: sha512-HfIhB5ruTdQv0XX2XlncWQiJ5SIHZ7NHZhVyHth0CSZ/xzge00etRyYy/3wp/Dsu+PkxMC+6+B2lS/GcKoewkA==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@ -4657,8 +4659,8 @@ packages:
prettier: ^3.0.0
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
prettier@3.3.3:
resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==}
prettier@3.4.1:
resolution: {integrity: sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==}
engines: {node: '>=14'}
hasBin: true
@ -4948,11 +4950,11 @@ packages:
std-env@3.7.0:
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
stoker@1.3.0:
resolution: {integrity: sha512-ywRokjO8jKb65z6qJVJbzilQJzcoly8/bwyodp6sZC0ZQmn/zfDCOkjcw6hhNKWE8eSVGanZUIXEPab5MTxnmA==}
stoker@1.4.2:
resolution: {integrity: sha512-zna86ZzC3fnMOIkuO+1vRMfcRw7SpC/7yafRb0u8DwDVig2pPh6POVnGB7t2A5t/rMvyr7hE7tjXTPvW8bhJKg==}
peerDependencies:
'@asteasolutions/zod-to-openapi': ^7.0.0
'@hono/zod-openapi': ^0.16.0
'@hono/zod-openapi': '>=0.16.0'
hono: ^4.0.0
openapi3-ts: ^4.4.0
peerDependenciesMeta:
@ -5263,9 +5265,6 @@ packages:
'@swc/wasm':
optional: true
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.4.0:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
@ -5280,10 +5279,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsyringe@4.8.0:
resolution: {integrity: sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==}
engines: {node: '>= 6.0.0'}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -6328,25 +6323,25 @@ snapshots:
'@hapi/hoek': 9.3.0
optional: true
'@hono/swagger-ui@0.4.1(hono@4.6.11)':
'@hono/swagger-ui@0.4.1(hono@4.6.12)':
dependencies:
hono: 4.6.11
hono: 4.6.12
'@hono/zod-openapi@0.15.3(hono@4.6.11)(zod@3.23.8)':
'@hono/zod-openapi@0.15.3(hono@4.6.12)(zod@3.23.8)':
dependencies:
'@asteasolutions/zod-to-openapi': 7.1.2(zod@3.23.8)
'@hono/zod-validator': 0.2.2(hono@4.6.11)(zod@3.23.8)
hono: 4.6.11
'@hono/zod-validator': 0.2.2(hono@4.6.12)(zod@3.23.8)
hono: 4.6.12
zod: 3.23.8
'@hono/zod-validator@0.2.2(hono@4.6.11)(zod@3.23.8)':
'@hono/zod-validator@0.2.2(hono@4.6.12)(zod@3.23.8)':
dependencies:
hono: 4.6.11
hono: 4.6.12
zod: 3.23.8
'@hono/zod-validator@0.4.1(hono@4.6.11)(zod@3.23.8)':
'@hono/zod-validator@0.4.1(hono@4.6.12)(zod@3.23.8)':
dependencies:
hono: 4.6.11
hono: 4.6.12
zod: 3.23.8
'@humanwhocodes/config-array@0.13.0':
@ -6490,12 +6485,12 @@ snapshots:
- babel-plugin-macros
- debug
'@inlang/paraglide-sveltekit@0.11.5(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))':
'@inlang/paraglide-sveltekit@0.11.5(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))':
dependencies:
'@inlang/paraglide-js': 1.11.3
'@inlang/paraglide-vite': 1.2.76
'@lix-js/client': 2.2.1
'@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
commander: 12.1.0
dedent: 1.5.1
devalue: 4.3.3
@ -6671,7 +6666,7 @@ snapshots:
'@internationalized/date': 3.6.0
dequal: 2.0.3
focus-trap: 7.6.0
nanoid: 5.0.8
nanoid: 5.0.9
svelte: 5.0.0-next.175
'@melt-ui/svelte@0.83.0(svelte@5.0.0-next.175)':
@ -6681,7 +6676,7 @@ snapshots:
'@internationalized/date': 3.6.0
dequal: 2.0.3
focus-trap: 7.6.0
nanoid: 5.0.8
nanoid: 5.0.9
svelte: 5.0.0-next.175
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
@ -7128,6 +7123,8 @@ snapshots:
dependencies:
'@noble/hashes': 1.5.0
'@phc/format@1.0.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@ -7229,17 +7226,17 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.24.0':
optional: true
'@scalar/hono-api-reference@0.5.161(hono@4.6.11)':
'@scalar/hono-api-reference@0.5.162(hono@4.6.12)':
dependencies:
'@scalar/types': 0.0.21
hono: 4.6.11
'@scalar/types': 0.0.22
hono: 4.6.12
'@scalar/openapi-types@0.1.5': {}
'@scalar/types@0.0.21':
'@scalar/types@0.0.22':
dependencies:
'@scalar/openapi-types': 0.1.5
'@unhead/schema': 1.11.11
'@unhead/schema': 1.11.13
'@sideway/address@4.1.5':
dependencies:
@ -7259,41 +7256,41 @@ snapshots:
'@sinclair/typebox@0.32.35':
optional: true
'@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))':
'@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))':
dependencies:
'@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
import-meta-resolve: 4.1.0
'@sveltejs/adapter-node@5.2.9(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))':
'@sveltejs/adapter-node@5.2.9(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))':
dependencies:
'@rollup/plugin-commonjs': 28.0.1(rollup@4.24.0)
'@rollup/plugin-json': 6.1.0(rollup@4.24.0)
'@rollup/plugin-node-resolve': 15.3.0(rollup@4.24.0)
'@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
rollup: 4.24.0
'@sveltejs/adapter-vercel@5.4.8(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))':
'@sveltejs/adapter-vercel@5.4.8(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))':
dependencies:
'@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
'@vercel/nft': 0.27.4
esbuild: 0.21.5
transitivePeerDependencies:
- encoding
- supports-color
'@sveltejs/enhanced-img@0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))':
'@sveltejs/enhanced-img@0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))':
dependencies:
magic-string: 0.30.11
svelte: 5.0.0-next.175
svelte-parse-markup: 0.1.5(svelte@5.0.0-next.175)
vite: 5.4.11(@types/node@20.17.7)
vite: 5.4.11(@types/node@20.17.8)
vite-imagetools: 7.0.4(rollup@4.24.0)
transitivePeerDependencies:
- rollup
'@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))':
'@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))':
dependencies:
'@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
'@types/cookie': 0.6.0
cookie: 0.6.0
devalue: 5.1.1
@ -7307,27 +7304,27 @@ snapshots:
sirv: 3.0.0
svelte: 5.0.0-next.175
tiny-glob: 0.2.9
vite: 5.4.11(@types/node@20.17.7)
vite: 5.4.11(@types/node@20.17.8)
'@sveltejs/vite-plugin-svelte-inspector@3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))':
'@sveltejs/vite-plugin-svelte-inspector@3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))':
dependencies:
'@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
debug: 4.3.7
svelte: 5.0.0-next.175
vite: 5.4.11(@types/node@20.17.7)
vite: 5.4.11(@types/node@20.17.8)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))':
'@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/vite-plugin-svelte-inspector': 3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
debug: 4.3.7
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.11
svelte: 5.0.0-next.175
vite: 5.4.11(@types/node@20.17.7)
vitefu: 1.0.2(vite@5.4.11(@types/node@20.17.7))
vite: 5.4.11(@types/node@20.17.8)
vitefu: 1.0.2(vite@5.4.11(@types/node@20.17.8))
transitivePeerDependencies:
- supports-color
@ -7368,21 +7365,21 @@ snapshots:
'@types/jsonwebtoken@9.0.7':
dependencies:
'@types/node': 20.17.7
'@types/node': 20.17.8
'@types/node@20.17.7':
'@types/node@20.17.8':
dependencies:
undici-types: 6.19.8
'@types/pg@8.11.10':
dependencies:
'@types/node': 20.17.7
'@types/node': 20.17.8
pg-protocol: 1.7.0
pg-types: 4.0.2
'@types/pg@8.11.6':
dependencies:
'@types/node': 20.17.7
'@types/node': 20.17.8
pg-protocol: 1.7.0
pg-types: 4.0.2
@ -7390,7 +7387,7 @@ snapshots:
'@types/qrcode@1.5.5':
dependencies:
'@types/node': 20.17.7
'@types/node': 20.17.8
'@types/resolve@1.20.2': {}
@ -7494,7 +7491,7 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@unhead/schema@1.11.11':
'@unhead/schema@1.11.13':
dependencies:
hookable: 5.5.3
zhead: 2.2.4
@ -7633,9 +7630,11 @@ snapshots:
aproba@2.0.0: {}
arctic@1.9.2:
arctic@2.3.0:
dependencies:
oslo: 1.2.0
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
'@oslojs/jwt': 0.2.0
are-we-there-yet@2.0.0:
dependencies:
@ -7646,6 +7645,12 @@ snapshots:
arg@5.0.2: {}
argon2@0.41.1:
dependencies:
'@phc/format': 1.0.0
node-addon-api: 8.2.2
node-gyp-build: 4.8.2
argparse@2.0.1: {}
aria-query@5.3.2: {}
@ -7706,7 +7711,7 @@ snapshots:
dependencies:
'@internationalized/date': 3.6.0
'@melt-ui/svelte': 0.76.2(svelte@5.0.0-next.175)
nanoid: 5.0.8
nanoid: 5.0.9
svelte: 5.0.0-next.175
boardgamegeekclient@1.9.1:
@ -7844,9 +7849,9 @@ snapshots:
validator: 13.12.0
optional: true
class-variance-authority@0.7.0:
class-variance-authority@0.7.1:
dependencies:
clsx: 2.0.0
clsx: 2.1.1
classnames@2.5.1: {}
@ -7860,8 +7865,6 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.0.0: {}
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
@ -7977,8 +7980,7 @@ snapshots:
dateformat@4.6.3: {}
dayjs@1.11.13:
optional: true
dayjs@1.11.13: {}
debug@2.6.9:
dependencies:
@ -8500,11 +8502,11 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
formsnap@1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)):
formsnap@1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)):
dependencies:
nanoid: 5.0.8
nanoid: 5.0.9
svelte: 5.0.0-next.175
sveltekit-superforms: 2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)
sveltekit-superforms: 2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)
forwarded@0.2.0: {}
@ -8639,24 +8641,24 @@ snapshots:
help-me@5.0.0: {}
hono-pino@0.7.0(hono@4.6.11)(pino@9.5.0):
hono-pino@0.7.0(hono@4.6.12)(pino@9.5.0):
dependencies:
defu: 6.1.4
hono: 4.6.11
hono: 4.6.12
pino: 9.5.0
hono-rate-limiter@0.4.0(hono@4.6.11):
hono-rate-limiter@0.4.0(hono@4.6.12):
dependencies:
hono: 4.6.11
hono: 4.6.12
hono-zod-openapi@0.5.0(hono@4.6.11)(zod@3.23.8):
hono-zod-openapi@0.5.0(hono@4.6.12)(zod@3.23.8):
dependencies:
'@hono/zod-validator': 0.4.1(hono@4.6.11)(zod@3.23.8)
hono: 4.6.11
'@hono/zod-validator': 0.4.1(hono@4.6.12)(zod@3.23.8)
hono: 4.6.12
zod: 3.23.8
zod-openapi: 4.0.0(zod@3.23.8)
hono@4.6.11: {}
hono@4.6.12: {}
hookable@5.5.3: {}
@ -9056,9 +9058,9 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.7: {}
nanoid@3.3.8: {}
nanoid@5.0.8: {}
nanoid@5.0.9: {}
natural-compare@1.4.0: {}
@ -9068,6 +9070,8 @@ snapshots:
node-abort-controller@3.1.1: {}
node-addon-api@8.2.2: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@ -9160,11 +9164,6 @@ snapshots:
'@node-rs/argon2': 1.7.0
'@node-rs/bcrypt': 1.9.0
oslo@1.2.1:
dependencies:
'@node-rs/argon2': 1.7.0
'@node-rs/bcrypt': 1.9.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@ -9460,13 +9459,13 @@ snapshots:
'@csstools/utilities': 1.0.0(postcss@8.4.49)
postcss: 8.4.49
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)):
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)):
dependencies:
lilconfig: 3.1.2
yaml: 2.5.1
optionalDependencies:
postcss: 8.4.49
ts-node: 10.9.2(@types/node@20.17.7)(typescript@5.7.2)
ts-node: 10.9.2(@types/node@20.17.8)(typescript@5.7.2)
postcss-load-config@5.1.0(jiti@1.21.6)(postcss@8.4.49)(tsx@4.19.2):
dependencies:
@ -9600,7 +9599,7 @@ snapshots:
postcss@8.4.49:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1
@ -9637,12 +9636,12 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-plugin-svelte@3.3.2(prettier@3.3.3)(svelte@5.0.0-next.175):
prettier-plugin-svelte@3.3.2(prettier@3.4.1)(svelte@5.0.0-next.175):
dependencies:
prettier: 3.3.3
prettier: 3.4.1
svelte: 5.0.0-next.175
prettier@3.3.3: {}
prettier@3.4.1: {}
pretty-format@29.7.0:
dependencies:
@ -9965,13 +9964,13 @@ snapshots:
std-env@3.7.0: {}
stoker@1.3.0(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.11)(zod@3.23.8))(hono@4.6.11)(openapi3-ts@4.4.0):
stoker@1.4.2(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.12)(zod@3.23.8))(hono@4.6.12)(openapi3-ts@4.4.0):
dependencies:
'@asteasolutions/zod-to-openapi': 7.1.2(zod@3.23.8)
hono: 4.6.11
hono: 4.6.12
openapi3-ts: 4.4.0
optionalDependencies:
'@hono/zod-openapi': 0.15.3(hono@4.6.11)(zod@3.23.8)
'@hono/zod-openapi': 0.15.3(hono@4.6.12)(zod@3.23.8)
string-width@4.2.3:
dependencies:
@ -10146,14 +10145,14 @@ snapshots:
magic-string: 0.30.11
zimmerframe: 1.1.2
sveltekit-flash-message@2.4.4(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175):
sveltekit-flash-message@2.4.4(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175):
dependencies:
'@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
svelte: 5.0.0-next.175
sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2):
sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2):
dependencies:
'@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))
'@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))
devalue: 5.1.1
just-clone: 6.2.0
memoize-weak: 1.0.2
@ -10184,16 +10183,16 @@ snapshots:
tailwind-merge@2.5.5: {}
tailwind-variants@0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))):
tailwind-variants@0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))):
dependencies:
tailwind-merge: 2.5.5
tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))
tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))
tailwindcss-animate@1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))):
tailwindcss-animate@1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))):
dependencies:
tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))
tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))
tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)):
tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@ -10212,7 +10211,7 @@ snapshots:
postcss: 8.4.49
postcss-import: 15.1.0(postcss@8.4.49)
postcss-js: 4.0.1(postcss@8.4.49)
postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))
postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))
postcss-nested: 6.2.0(postcss@8.4.49)
postcss-selector-parser: 6.1.2
resolve: 1.22.8
@ -10283,14 +10282,14 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2):
ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 20.17.7
'@types/node': 20.17.8
acorn: 8.12.1
acorn-walk: 8.3.4
arg: 4.1.3
@ -10301,8 +10300,6 @@ snapshots:
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
tslib@1.14.1: {}
tslib@2.4.0:
optional: true
@ -10317,10 +10314,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsyringe@4.8.0:
dependencies:
tslib: 1.14.1
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -10403,13 +10396,13 @@ snapshots:
transitivePeerDependencies:
- rollup
vite-node@1.6.0(@types/node@20.17.7):
vite-node@1.6.0(@types/node@20.17.8):
dependencies:
cac: 6.7.14
debug: 4.3.7
pathe: 1.1.2
picocolors: 1.1.0
vite: 5.4.11(@types/node@20.17.7)
vite: 5.4.11(@types/node@20.17.8)
transitivePeerDependencies:
- '@types/node'
- less
@ -10421,20 +10414,20 @@ snapshots:
- supports-color
- terser
vite@5.4.11(@types/node@20.17.7):
vite@5.4.11(@types/node@20.17.8):
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
rollup: 4.24.0
optionalDependencies:
'@types/node': 20.17.7
'@types/node': 20.17.8
fsevents: 2.3.3
vitefu@1.0.2(vite@5.4.11(@types/node@20.17.7)):
vitefu@1.0.2(vite@5.4.11(@types/node@20.17.8)):
optionalDependencies:
vite: 5.4.11(@types/node@20.17.7)
vite: 5.4.11(@types/node@20.17.8)
vitest@1.6.0(@types/node@20.17.7):
vitest@1.6.0(@types/node@20.17.8):
dependencies:
'@vitest/expect': 1.6.0
'@vitest/runner': 1.6.0
@ -10453,11 +10446,11 @@ snapshots:
strip-literal: 2.1.0
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.11(@types/node@20.17.7)
vite-node: 1.6.0(@types/node@20.17.7)
vite: 5.4.11(@types/node@20.17.8)
vite-node: 1.6.0(@types/node@20.17.8)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.17.7
'@types/node': 20.17.8
transitivePeerDependencies:
- less
- lightningcss

2
src/app.d.ts vendored
View file

@ -1,6 +1,6 @@
import type { ApiClient } from '$lib/server/api';
import type { Users } from '$lib/server/api/databases/postgres/tables';
import type { Session } from '$lib/server/api/services/sessions.service';
import type { Session } from '$lib/server/api/iam/sessions/sessions.service';
import type { parseApiResponse } from '$lib/utils/api';
// See https://svelte.dev/docs/kit/types#app.d.ts

View file

@ -1,46 +1,45 @@
import "reflect-metadata";
import { StatusCodes } from "$lib/constants/status-codes";
import type { ApiRoutes } from "$lib/server/api";
import { parseApiResponse } from "$lib/utils/api";
import { type Handle, redirect } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { hc } from "hono/client";
import { i18n } from "$lib/i18n";
import { i18n } from '$lib/i18n';
import type { ApiRoutes } from '$lib/server/api';
import { parseApiResponse } from '$lib/utils/api';
import { StatusCodes } from '$lib/utils/status-codes';
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { hc } from 'hono/client';
const handleParaglide: Handle = i18n.handle();
const apiClient: Handle = async ({ event, resolve }) => {
/* ------------------------------ Register api ------------------------------ */
const { api } = hc<ApiRoutes>("/", {
fetch: event.fetch,
headers: {
"x-forwarded-for": event.url.host.includes("sveltekit-prerender") ? "127.0.0.1" : event.getClientAddress(),
host: event.request.headers.get("host") || "",
},
});
/* ------------------------------ Register api ------------------------------ */
const { api } = hc<ApiRoutes>('/', {
fetch: event.fetch,
headers: {
'x-forwarded-for': event.url.host.includes('sveltekit-prerender') ? '127.0.0.1' : event.getClientAddress(),
host: event.request.headers.get('host') || '',
},
});
/* ----------------------------- Auth functions ----------------------------- */
async function getAuthedUser() {
const { data } = await api.me.$get().then(parseApiResponse);
return { user: data?.user, session: data?.session };
}
/* ----------------------------- Auth functions ----------------------------- */
async function getAuthedUser() {
const { data } = await api.me.$get().then(parseApiResponse);
return { user: data?.user, session: data?.session };
}
async function getAuthedUserOrThrow() {
const { data } = await api.me.$get().then(parseApiResponse);
if (!data || !data.user) {
throw redirect(StatusCodes.TEMPORARY_REDIRECT, "/");
}
return data?.user;
}
async function getAuthedUserOrThrow() {
const { data } = await api.me.$get().then(parseApiResponse);
if (!data || !data.user) {
throw redirect(StatusCodes.TEMPORARY_REDIRECT, '/');
}
return data?.user;
}
/* ------------------------------ Set contexts ------------------------------ */
event.locals.api = api;
event.locals.parseApiResponse = parseApiResponse;
event.locals.getAuthedUser = getAuthedUser;
event.locals.getAuthedUserOrThrow = getAuthedUserOrThrow;
/* ------------------------------ Set contexts ------------------------------ */
event.locals.api = api;
event.locals.parseApiResponse = parseApiResponse;
event.locals.getAuthedUser = getAuthedUser;
event.locals.getAuthedUserOrThrow = getAuthedUserOrThrow;
/* ----------------------------- Return response ---------------------------- */
return await resolve(event);
/* ----------------------------- Return response ---------------------------- */
return await resolve(event);
};
export const handle: Handle = sequence(apiClient, handleParaglide);

View file

@ -1,357 +0,0 @@
// Taken from https://github.com/prettymuchbryce/http-status-codes/blob/master/src/status-codes.ts
export enum StatusCodes {
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1
*
* This interim response indicates that everything so far is OK and that the client should continue with the request or ignore it if it is already finished.
*/
CONTINUE = 100,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2
*
* This code is sent in response to an Upgrade request header by the client, and indicates the protocol the server is switching too.
*/
SWITCHING_PROTOCOLS = 101,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.1
*
* This code indicates that the server has received and is processing the request, but no response is available yet.
*/
PROCESSING = 102,
/**
* Official Documentation @ https://www.rfc-editor.org/rfc/rfc8297#page-3
*
* This code indicates to the client that the server is likely to send a final response with the header fields included in the informational response.
*/
EARLY_HINTS = 103,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1
*
* The request has succeeded. The meaning of a success varies depending on the HTTP method:
* GET: The resource has been fetched and is transmitted in the message body.
* HEAD: The entity headers are in the message body.
* POST: The resource describing the result of the action is transmitted in the message body.
* TRACE: The message body contains the request message as received by the server
*/
OK = 200,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2
*
* The request has succeeded and a new resource has been created as a result of it. This is typically the response sent after a PUT request.
*/
CREATED = 201,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.3
*
* The request has been received but not yet acted upon. It is non-committal, meaning that there is no way in HTTP to later send an asynchronous response indicating the outcome of processing the request. It is intended for cases where another process or server handles the request, or for batch processing.
*/
ACCEPTED = 202,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.4
*
* This response code means returned meta-information set is not exact set as available from the origin server, but collected from a local or a third party copy. Except this condition, 200 OK response should be preferred instead of this response.
*/
NON_AUTHORITATIVE_INFORMATION = 203,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5
*
* There is no content to send for this request, but the headers may be useful. The user-agent may update its cached headers for this resource with the new ones.
*/
NO_CONTENT = 204,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.6
*
* This response code is sent after accomplishing request to tell user agent reset document view which sent this request.
*/
RESET_CONTENT = 205,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.1
*
* This response code is used because of range header sent by the client to separate download into multiple streams.
*/
PARTIAL_CONTENT = 206,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.2
*
* A Multi-Status response conveys information about multiple resources in situations where multiple status codes might be appropriate.
*/
MULTI_STATUS = 207,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.1
*
* The request has more than one possible responses. User-agent or user should choose one of them. There is no standardized way to choose one of the responses.
*/
MULTIPLE_CHOICES = 300,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.2
*
* This response code means that URI of requested resource has been changed. Probably, new URI would be given in the response.
*/
MOVED_PERMANENTLY = 301,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.3
*
* This response code means that URI of requested resource has been changed temporarily. New changes in the URI might be made in the future. Therefore, this same URI should be used by the client in future requests.
*/
MOVED_TEMPORARILY = 302,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.4
*
* Server sent this response to directing client to get requested resource to another URI with an GET request.
*/
SEE_OTHER = 303,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1
*
* This is used for caching purposes. It is telling to client that response has not been modified. So, client can continue to use same cached version of response.
*/
NOT_MODIFIED = 304,
/**
* @deprecated
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.6
*
* Was defined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. It has been deprecated due to security concerns regarding in-band configuration of a proxy.
*/
USE_PROXY = 305,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.7
*
* Server sent this response to directing client to get requested resource to another URI with same method that used prior request. This has the same semantic than the 302 Found HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request.
*/
TEMPORARY_REDIRECT = 307,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7538#section-3
*
* This means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header. This has the same semantics as the 301 Moved Permanently HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request.
*/
PERMANENT_REDIRECT = 308,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.1
*
* This response means that server could not understand the request due to invalid syntax.
*/
BAD_REQUEST = 400,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1
*
* Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.
*/
UNAUTHORIZED = 401,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2
*
* This response code is reserved for future use. Initial aim for creating this code was using it for digital payment systems however this is not used currently.
*/
PAYMENT_REQUIRED = 402,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3
*
* The client does not have access rights to the content, i.e. they are unauthorized, so server is rejecting to give proper response. Unlike 401, the client's identity is known to the server.
*/
FORBIDDEN = 403,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.4
*
* The server can not find requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 to hide the existence of a resource from an unauthorized client. This response code is probably the most famous one due to its frequent occurence on the web.
*/
NOT_FOUND = 404,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5
*
* The request method is known by the server but has been disabled and cannot be used. For example, an API may forbid DELETE-ing a resource. The two mandatory methods, GET and HEAD, must never be disabled and should not return this error code.
*/
METHOD_NOT_ALLOWED = 405,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.6
*
* This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content following the criteria given by the user agent.
*/
NOT_ACCEPTABLE = 406,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.2
*
* This is similar to 401 but authentication is needed to be done by a proxy.
*/
PROXY_AUTHENTICATION_REQUIRED = 407,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7
*
* This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also note that some servers merely shut down the connection without sending this message.
*/
REQUEST_TIMEOUT = 408,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8
*
* This response is sent when a request conflicts with the current state of the server.
*/
CONFLICT = 409,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9
*
* This response would be sent when the requested content has been permenantly deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code.
*/
GONE = 410,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.10
*
* The server rejected the request because the Content-Length header field is not defined and the server requires it.
*/
LENGTH_REQUIRED = 411,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.2
*
* The client has indicated preconditions in its headers which the server does not meet.
*/
PRECONDITION_FAILED = 412,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11
*
* Request entity is larger than limits defined by server; the server might close the connection or return an Retry-After header field.
*/
REQUEST_TOO_LONG = 413,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.12
*
* The URI requested by the client is longer than the server is willing to interpret.
*/
REQUEST_URI_TOO_LONG = 414,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13
*
* The media format of the requested data is not supported by the server, so the server is rejecting the request.
*/
UNSUPPORTED_MEDIA_TYPE = 415,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.4
*
* The range specified by the Range header field in the request can't be fulfilled; it's possible that the range is outside the size of the target URI's data.
*/
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.14
*
* This response code means the expectation indicated by the Expect request header field can't be met by the server.
*/
EXPECTATION_FAILED = 417,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2324#section-2.3.2
*
* Any attempt to brew coffee with a teapot should result in the error code "418 I'm a teapot". The resulting entity body MAY be short and stout.
*/
IM_A_TEAPOT = 418,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6
*
* The 507 (Insufficient Storage) status code means the method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request. This condition is considered to be temporary. If the request which received this status code was the result of a user action, the request MUST NOT be repeated until it is requested by a separate user action.
*/
INSUFFICIENT_SPACE_ON_RESOURCE = 419,
/**
* @deprecated
* Official Documentation @ https://tools.ietf.org/rfcdiff?difftype=--hwdiff&url2=draft-ietf-webdav-protocol-06.txt
*
* A deprecated response used by the Spring Framework when a method has failed.
*/
METHOD_FAILURE = 420,
/**
* Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7540#section-9.1.2
*
* Defined in the specification of HTTP/2 to indicate that a server is not able to produce a response for the combination of scheme and authority that are included in the request URI.
*/
MISDIRECTED_REQUEST = 421,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3
*
* The request was well-formed but was unable to be followed due to semantic errors.
*/
UNPROCESSABLE_ENTITY = 422,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.4
*
* The resource that is being accessed is locked.
*/
LOCKED = 423,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.5
*
* The request failed due to failure of a previous request.
*/
FAILED_DEPENDENCY = 424,
/**
* Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.15
*
* The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol.
*/
UPGRADE_REQUIRED = 426,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-3
*
* The origin server requires the request to be conditional. Intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.
*/
PRECONDITION_REQUIRED = 428,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4
*
* The user has sent too many requests in a given amount of time ("rate limiting").
*/
TOO_MANY_REQUESTS = 429,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5
*
* The server is unwilling to process the request because its header fields are too large. The request MAY be resubmitted after reducing the size of the request header fields.
*/
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7725
*
* The user-agent requested a resource that cannot legally be provided, such as a web page censored by a government.
*/
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.1
*
* The server encountered an unexpected condition that prevented it from fulfilling the request.
*/
INTERNAL_SERVER_ERROR = 500,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2
*
* The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that must not return this code) are GET and HEAD.
*/
NOT_IMPLEMENTED = 501,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.3
*
* This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.
*/
BAD_GATEWAY = 502,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.4
*
* The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This responses should be used for temporary conditions and the Retry-After: HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached.
*/
SERVICE_UNAVAILABLE = 503,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.5
*
* This error response is given when the server is acting as a gateway and cannot get a response in time.
*/
GATEWAY_TIMEOUT = 504,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.6
*
* The HTTP version used in the request is not supported by the server.
*/
HTTP_VERSION_NOT_SUPPORTED = 505,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6
*
* The server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process.
*/
INSUFFICIENT_STORAGE = 507,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-6
*
* The 511 status code indicates that the client needs to authenticate to gain network access.
*/
NETWORK_AUTHENTICATION_REQUIRED = 511
}

View file

@ -0,0 +1,74 @@
import { CollectionController } from '$lib/server/api/collections/collection.controller';
import { LoginController } from '$lib/server/api/login/login.controller';
import { MfaController } from '$lib/server/api/mfa/mfa.controller';
import { OAuthController } from '$lib/server/api/oauth/oauth.controller';
import { SignupController } from '$lib/server/api/signup/signup.controller';
import { WishlistController } from '$lib/server/api/wishlists/wishlist.controller';
import { inject, injectable } from '@needle-di/core';
import { contextStorage } from 'hono/context-storage';
import { requestId } from 'hono/request-id';
import { notFound, onError, serveEmojiFavicon } from 'stoker/middlewares';
import { RootController } from './common/factories/controllers.factory';
import { browserSessions } from './common/middleware/browser-session.middleware';
import { pinoLogger } from './common/middleware/pino-logger.middleware';
import { rateLimit } from './common/middleware/rate-limit.middleware';
import { sessionManagement } from './common/middleware/session-management.middleware';
import { generateId } from './common/utils/crypto';
import configureOpenAPI from './configure-open-api';
import { IamController } from './iam/iam.controller';
import { UsersController } from './users/users.controller';
@injectable()
export class ApplicationController extends RootController {
constructor(
private loginController = inject(LoginController),
private oAuthController = inject(OAuthController),
private signupController = inject(SignupController),
private mfaController = inject(MfaController),
private iamController = inject(IamController),
private usersController = inject(UsersController),
private wishlistController = inject(WishlistController),
private collectionController = inject(CollectionController),
) {
super();
}
routes() {
return this.controller
.get('/', (c) => {
return c.json({ status: 'ok' });
})
.get('/healthz', (c) => {
return c.json({ message: 'Server is healthy' });
})
.get('/rate-limit', rateLimit({ limit: 3, minutes: 1 }), (c) => {
return c.json({ message: 'Test!' });
});
}
registerControllers() {
const app = this.controller;
app.onError(onError);
app.notFound(notFound);
app
.basePath('/api')
.use(requestId({ generator: () => generateId() }))
.use(contextStorage())
.use(browserSessions)
.use(sessionManagement)
.use(serveEmojiFavicon('📝'))
.use(pinoLogger())
.route('/', this.routes())
.route('/iam', this.iamController.routes())
.route('/users', this.usersController.routes())
.route('/login', this.loginController.routes())
.route('/oauth', this.oAuthController.routes())
.route('/signup', this.signupController.routes())
.route('/wishlists', this.wishlistController.routes())
.route('/collections', this.collectionController.routes())
.route('/mfa', this.mfaController.routes());
configureOpenAPI(app);
return app;
}
}

View file

@ -0,0 +1,42 @@
import { inject, injectable } from '@needle-di/core';
import { ApplicationController } from './application.controller';
import { ConfigService } from './common/configs/config.service';
// import { StorageService } from './storage/storage.service';
@injectable()
export class ApplicationModule {
constructor(
private applicationController = inject(ApplicationController),
private configService = inject(ConfigService),
// private storageService = inject(StorageService),
) {}
async app() {
return this.applicationController.registerControllers();
}
async start() {
const app = this.app();
await this.onApplicationStartup();
// register shutdown hooks
process.on('SIGINT', this.onApplicationShutdown);
process.on('SIGTERM', this.onApplicationShutdown);
console.log(`Api started on port ${this.configService.envs.PORT}`);
return app;
}
private async onApplicationStartup() {
console.log('Application startup...');
// validate configs
this.configService.validateEnvs();
// configure storage service
// await this.storageService.configure();
}
private onApplicationShutdown() {
console.log('Shutting down...');
process.exit();
}
}

View file

@ -1,10 +1,10 @@
import { StatusCodes } from '$lib/constants/status-codes';
import { allCollections, getCollectionByCUID, numberOfCollections } from '$lib/server/api/collections/collection.routes';
import { CollectionsService } from '$lib/server/api/collections/collections.service';
import { requireFullAuth } from '$lib/server/api/common/middleware/require-auth.middleware';
import { Controller } from '$lib/server/api/common/types/controller';
import { allCollections, getCollectionByCUID, numberOfCollections } from '$lib/server/api/controllers/collection.routes';
import { CollectionsService } from '$lib/server/api/services/collections.service';
import { StatusCodes } from '$lib/utils/status-codes';
import { inject, injectable } from '@needle-di/core';
import { openApi } from 'hono-zod-openapi';
import { injectable, inject } from '@needle-di/core';
import { requireFullAuth } from '../middleware/require-auth.middleware';
@injectable()
export class CollectionController extends Controller {

View file

@ -0,0 +1,68 @@
import cuidParamsSchema from '$lib/server/api/common/openapi/cuidParamsSchema';
import { unauthorizedSchema } from '$lib/server/api/common/utils/exceptions';
import { StatusCodes } from '$lib/utils/status-codes';
import { z } from '@hono/zod-openapi';
import { createErrorSchema } from 'stoker/openapi/schemas';
import { taggedAuthRoute } from '../common/openapi/create-auth-route';
import { selectCollectionSchema } from '../databases/postgres/tables';
const tag = 'Collection';
export const allCollections = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectCollectionSchema,
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const numberOfCollections = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'User profile',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const getCollectionByCUID = taggedAuthRoute(tag, {
request: {
param: {
schema: cuidParamsSchema,
example: { cuid: 'z6uiuc9qz82xjf5dexc5kr2d' },
},
},
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectCollectionSchema,
mediaType: 'application/json',
},
[StatusCodes.NOT_FOUND]: {
description: 'The collection does not exist',
schema: z.object({ message: z.string() }).openapi({
example: {
message: 'The collection does not exist',
},
}),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});

View file

@ -0,0 +1,83 @@
import { takeFirstOrThrow } from '$lib/server/api/common/utils/repository';
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, eq } from 'drizzle-orm';
import { collections } from '../databases/postgres/tables';
export type CreateCollection = InferInsertModel<typeof collections>;
export type UpdateCollection = Partial<CreateCollection>;
@injectable()
export class CollectionsRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findAll(db = this.drizzle.db) {
return db.query.collections.findMany();
}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.collections.findFirst({
where: eq(collections.id, id),
columns: {
cuid: true,
name: true,
},
});
}
async findOneByCuid(cuid: string, db = this.drizzle.db) {
return db.query.collections.findFirst({
where: eq(collections.cuid, cuid),
columns: {
cuid: true,
name: true,
},
});
}
async findOneByUserId(userId: string, db = this.drizzle.db) {
return db.query.collections.findFirst({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
},
});
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.collections.findMany({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
createdAt: true,
},
});
}
async findAllByUserIdWithDetails(userId: string, db = this.drizzle.db) {
return db.query.collections.findMany({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
},
with: {
collection_items: {
columns: {
cuid: true,
},
},
},
});
}
async create(data: CreateCollection, db = this.drizzle.db) {
return db.insert(collections).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateCollection, db = this.drizzle.db) {
return db.update(collections).set(data).where(eq(collections.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -0,0 +1,50 @@
import type { db } from '$lib/server/api/packages/drizzle';
import { generateRandomAnimalName } from '$lib/utils/randomDataUtil';
import { inject, injectable } from '@needle-di/core';
import { CollectionsRepository } from './collections.repository';
@injectable()
export class CollectionsService {
constructor(private collectionsRepository = inject(CollectionsRepository)) {}
async findOneByUserId(userId: string) {
return this.collectionsRepository.findOneByUserId(userId);
}
async findAllByUserId(userId: string) {
return this.collectionsRepository.findAllByUserId(userId);
}
async findAllByUserIdWithDetails(userId: string) {
return this.collectionsRepository.findAllByUserIdWithDetails(userId);
}
async findOneById(id: string) {
return this.collectionsRepository.findOneById(id);
}
async findOneByCuid(cuid: string) {
return this.collectionsRepository.findOneByCuid(cuid);
}
async createEmptyNoName(userId: string, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.createEmpty(userId, null, trx);
}
async createEmpty(userId: string, name: string | null, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
if (!trx) {
return this.collectionsRepository.create({
user_id: userId,
name: name ?? generateRandomAnimalName(),
});
}
return this.collectionsRepository.create(
{
user_id: userId,
name: name ?? generateRandomAnimalName(),
},
trx,
);
}
}

View file

@ -0,0 +1,32 @@
import * as envs from '$env/static/private';
import { injectable } from '@needle-di/core';
import { z } from 'zod';
import { type EnvsDto, envsDto } from './dtos/env.dto';
@injectable()
export class ConfigService {
envs: EnvsDto;
constructor() {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
this.envs = this.parseEnvs()!;
}
private parseEnvs() {
return envsDto.parse(envs);
}
validateEnvs() {
try {
return envsDto.parse(envs);
} catch (err) {
if (err instanceof z.ZodError) {
const { fieldErrors } = err.flatten();
const errorMessage = Object.entries(fieldErrors)
.map(([field, errors]) => (errors ? `${field}: ${errors.join(', ')}` : field))
.join('\n ');
throw new Error(`Missing environment variables:\n ${errorMessage}`);
}
}
}
}

View file

@ -0,0 +1,39 @@
import { z } from 'zod';
const stringBoolean = z.coerce
.string()
.transform((val) => {
return val === 'true';
})
.default('false');
export const envsDto = z.object({
DATABASE_USER: z.string(),
DATABASE_PASSWORD: z.string(),
DATABASE_HOST: z.string(),
DATABASE_PORT: z.coerce.number(),
DATABASE_DB: z.string(),
DB_MIGRATING: stringBoolean,
DB_SEEDING: stringBoolean,
DOMAIN: z.string(),
ENCRYPTION_KEY: z.string(),
ENV: z.enum(['dev', 'prod']),
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
NODE_ENV: z.string().default('development'),
ORIGIN: z.string(),
PORT: z.number({ coerce: true }).default(5173),
PUBLIC_SITE_NAME: z.string(),
PUBLIC_SITE_URL: z.string(),
PUBLIC_UMAMI_DO_NOT_TRACK: z.string().default('true'),
PUBLIC_UMAMI_ID: z.string(),
PUBLIC_UMAMI_URL: z.string(),
REDIS_URL: z.string(),
SIGNING_SECRET: z.string(),
TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000),
});
export type EnvsDto = z.infer<typeof envsDto>;

View file

@ -1,39 +1,39 @@
import env from "$lib/server/api/common/env";
import type { AppBindings } from "$lib/server/api/common/types/hono";
import { validateAuthSession, verifyOrigin } from "$lib/server/api/middleware/auth.middleware";
import { pinoLogger } from "$lib/server/api/middleware/pino-logger.middleware";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { requestId } from "hono/request-id";
import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares";
import { generateId } from "./utils/crypto";
import env from '$lib/server/api/common/env';
import { validateAuthSession, verifyOrigin } from '$lib/server/api/common/middleware/auth.middleware';
import { pinoLogger } from '$lib/server/api/common/middleware/pino-logger.middleware';
import type { AppBindings } from '$lib/server/api/common/types/hono';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id';
import { notFound, onError, serveEmojiFavicon } from 'stoker/middlewares';
import { generateId } from './utils/crypto';
export function createRouter() {
return new Hono<AppBindings>({
strict: false,
}).basePath("/api");
return new Hono<AppBindings>({
strict: false,
}).basePath('/api');
}
export default function createApp() {
const app = createRouter();
const app = createRouter();
app.use(verifyOrigin).use(validateAuthSession);
app.use(serveEmojiFavicon("📝"));
app.use(requestId({ generator: () => generateId() })).use(pinoLogger());
app.use(verifyOrigin).use(validateAuthSession);
app.use(serveEmojiFavicon('📝'));
app.use(requestId({ generator: () => generateId() })).use(pinoLogger());
app.notFound(notFound);
app.onError(onError);
app.notFound(notFound);
app.onError(onError);
app.use(
"/*",
cors({
origin: [env.ORIGIN],
app.use(
'/*',
cors({
origin: [env.ORIGIN],
allowMethods: ["POST"],
allowHeaders: ["Content-Type"],
// credentials: true, // If you need to send cookies or HTTP authentication
}),
);
allowMethods: ['POST'],
allowHeaders: ['Content-Type'],
// credentials: true, // If you need to send cookies or HTTP authentication
}),
);
return app;
return app;
}

View file

@ -1,6 +1,6 @@
import { config } from 'dotenv';
import { expand } from 'dotenv-expand';
import { z, type ZodError } from 'zod';
import { type ZodError, z } from 'zod';
expand(config());
@ -37,9 +37,9 @@ const EnvSchema = z.object({
TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000),
});
export type env = z.infer<typeof EnvSchema>;
export type EnvsDto = z.infer<typeof EnvSchema>;
let env: env;
let env: EnvsDto;
try {
env = EnvSchema.parse(process.env);

View file

@ -0,0 +1,13 @@
import { createHono } from '../utils/hono';
export abstract class Controller {
protected readonly controller: ReturnType<typeof createHono>;
constructor() {
this.controller = createHono();
}
abstract routes(): ReturnType<typeof createHono>;
}
export abstract class RootController extends Controller {
abstract registerControllers(): ReturnType<typeof createHono>;
}

View file

@ -0,0 +1,9 @@
import { Container } from '@needle-di/core';
import { DrizzleService } from '../../databases/postgres/drizzle.service';
export abstract class DrizzleRepository {
protected readonly drizzle: DrizzleService;
constructor() {
this.drizzle = new Container().get(DrizzleService);
}
}

View file

@ -0,0 +1,7 @@
import { Container } from '@needle-di/core';
import { RedisService } from '../../databases/redis/redis.service';
export abstract class RedisRepository<T extends string> {
protected readonly redis = new Container().get(RedisService);
readonly prefix: T | string = '';
}

View file

@ -0,0 +1,47 @@
import { Unauthorized } from '$lib/server/api/common/utils/exceptions';
import type { SessionDto } from '$lib/server/api/iam/sessions/dtos/session.dto';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { Container } from '@needle-di/core';
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
/* ---------------------------------- Types --------------------------------- */
type AuthStates = 'session' | 'none';
type AuthedReturnType = typeof authed;
type UnauthedReturnType = typeof unauthed;
/* ------------------- Overloaded function implementation ------------------- */
// we have to overload the implementation to provide the correct return type
export function authState(state: 'session'): AuthedReturnType;
export function authState(state: 'none'): UnauthedReturnType;
export function authState(state: AuthStates): AuthedReturnType | UnauthedReturnType {
if (state === 'session') return authed;
return unauthed;
}
// resolve dependencies from the container
const sessionService = new Container().get(SessionsService);
/* ------------------------------ Require Auth ------------------------------ */
const authed: MiddlewareHandler<{
Variables: {
session: SessionDto;
};
}> = createMiddleware(async (c, next) => {
if (!c.var.session) {
throw Unauthorized('You must be logged in to access this resource');
}
return next();
});
/* ---------------------------- Require Unauthed ---------------------------- */
const unauthed: MiddlewareHandler<{
Variables: {
session: null;
};
}> = createMiddleware(async (c, next) => {
if (c.var.session) {
throw Unauthorized('You must be logged out to access this resource');
}
return next();
});

View file

@ -0,0 +1,27 @@
import { Container } from '@needle-di/core';
import type { MiddlewareHandler } from 'hono';
import { getSignedCookie, setSignedCookie } from 'hono/cookie';
import { createMiddleware } from 'hono/factory';
import { ConfigService } from '../configs/config.service';
import { generateId } from '../utils/crypto';
export const browserSessions: MiddlewareHandler = createMiddleware(async (c, next) => {
const BROWSER_SESSION_COOKIE_NAME = '_id';
const container = new Container();
const configService = container.get(ConfigService);
const browserSessionCookie = await getSignedCookie(c, configService.envs.SIGNING_SECRET, BROWSER_SESSION_COOKIE_NAME);
let browserSessionId = browserSessionCookie;
if (!browserSessionCookie) {
browserSessionId = generateId();
setSignedCookie(c, BROWSER_SESSION_COOKIE_NAME, browserSessionId, configService.envs.SIGNING_SECRET, {
httpOnly: true,
sameSite: 'lax',
secure: configService.envs.ENV === 'prod',
path: '/',
});
}
c.set('browserSessionId', browserSessionId);
return next();
});

View file

@ -0,0 +1,35 @@
import { Container } from '@needle-di/core';
import type { Context } from 'hono';
import { rateLimiter } from 'hono-rate-limiter';
import { RedisStore } from 'rate-limit-redis';
import { RedisService } from '../../databases/redis/redis.service';
import type { HonoEnv } from '../utils/hono';
const container = new Container();
const client = container.get(RedisService).redis;
export function rateLimit({
limit,
minutes,
key = '',
}: {
limit: number;
minutes: number;
key?: string;
}) {
return rateLimiter({
windowMs: minutes * 60 * 1000, // every x minutes
limit, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header
keyGenerator: (c: Context<HonoEnv>) => {
const clientKey = c.var.session?.userId || c.req.header('x-forwarded-for');
const pathKey = key || c.req.routePath;
return `${clientKey}_${pathKey}`;
}, // Method to generate custom identifiers for clients.
// Redis store configuration
store: new RedisStore({
// @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis
sendCommand: (...args: string[]) => client.call(...args),
}) as never,
});
}

View file

@ -1,4 +1,4 @@
import { Unauthorized } from '$lib/server/api/common/exceptions';
import { Unauthorized } from '$lib/server/api/common/utils/exceptions';
import type { Sessions, Users } from '$lib/server/api/databases/postgres/tables';
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
@ -17,14 +17,14 @@ export const requireFullAuth: MiddlewareHandler<{
});
export const requireTempAuth: MiddlewareHandler<{
Variables: {
session: Sessions;
user: Users;
};
Variables: {
session: Sessions;
user: Users;
};
}> = createMiddleware(async (c, next) => {
const session = c.var.session;
if (!session) {
throw Unauthorized('You must be logged in to access this resource');
}
return next();
const session = c.var.session;
if (!session) {
throw Unauthorized('You must be logged in to access this resource');
}
return next();
});

View file

@ -0,0 +1,30 @@
import { Container } from '@needle-di/core';
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
import { SessionsService } from '../../iam/sessions/sessions.service';
export const sessionManagement: MiddlewareHandler = createMiddleware(async (c, next) => {
const container = new Container();
const sessionService = container.get(SessionsService);
const sessionId = await sessionService.getSessionCookie();
if (!sessionId) {
c.set('session', null);
return next();
}
// Validate the session
const session = await sessionService.validateSession(sessionId);
// If the session is not found, delete the cookie
if (!session) sessionService.deleteSessionCookie();
// If the session is fresh, refresh the cookie with the new expiration date
if (session && session.fresh) sessionService.setSessionCookie(session);
// Set the session in the context
c.set('session', session);
// Continue to the next middleware
return next();
});

View file

@ -1,8 +1,8 @@
import { decodeBase64 } from '@oslojs/encoding';
import { createCipheriv, createDecipheriv } from 'crypto';
import { DynamicBuffer } from '@oslojs/binary';
import { injectable } from '@needle-di/core';
import { config } from '../common/config';
import { DynamicBuffer } from '@oslojs/binary';
import { decodeBase64 } from '@oslojs/encoding';
import { config } from '../config';
@injectable()
export class EncryptionService {

View file

@ -0,0 +1,38 @@
import { Container } from '@needle-di/core';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { HashingService } from './hashing.service';
describe('HashingService', () => {
let service: HashingService;
const container = new Container();
beforeAll(() => {
service = container.get(HashingService);
});
afterAll(() => {
vi.resetAllMocks();
});
describe('Create Hash', () => {
it('should create a hash', async () => {
const hash = await service.hash('111');
expect(hash).not.toBeUndefined();
expect(hash).not.toBeNull();
});
});
describe('Verify Hash', () => {
it('should verify a hash', async () => {
const hash = await service.hash('111');
const verifiable = await service.verify(hash, '111');
expect(verifiable).toBeTruthy();
});
it('should not verify a hash', async () => {
const hash = await service.hash('111');
const verifiable = await service.verify(hash, '222');
expect(verifiable).toBeFalsy();
});
});
});

View file

@ -0,0 +1,13 @@
import { injectable } from '@needle-di/core';
import { hash, verify } from 'argon2';
@injectable()
export class HashingService {
hash(data: string): Promise<string> {
return hash(data);
}
compare(data: string, encrypted: string): Promise<boolean> {
return verify(encrypted, data);
}
}

View file

@ -0,0 +1,22 @@
import { injectable } from '@needle-di/core';
import { getContext } from 'hono/context-storage';
import type { HonoEnv } from '../utils/hono';
@injectable()
export class RequestContextService {
getContext() {
return getContext<HonoEnv>();
}
getRequestId(): string {
return this.getContext().get('requestId');
}
getAuthedUserId() {
return this.getContext().get('session')?.userId;
}
getSession() {
return this.getContext().get('session');
}
}

View file

@ -0,0 +1,25 @@
import { inject, injectable } from '@needle-di/core';
import { generateId } from '../../common/utils/crypto';
import { HashingService } from '../../common/services/hashing.service';
@injectable()
export class VerificationCodesService {
constructor(private hashingService = inject(HashingService)) {}
async generateCodeWithHash() {
const verificationCode = this.generateCode();
const hashedVerificationCode = await this.hashingService.hash(verificationCode);
return { verificationCode, hashedVerificationCode };
}
verify(args: { verificationCode: string; hashedVerificationCode: string }) {
return this.hashingService.compare(args.verificationCode, args.hashedVerificationCode);
}
private generateCode() {
// alphabet with removed look-alike characters (0, 1, O, I)
const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ';
// generate 6 character long random string
return generateId(6, alphabet);
}
}

View file

@ -1,34 +1,34 @@
import type { Session } from '$lib/server/api/services/sessions.service';
import type { Users } from '$lib/server/api/databases/postgres/tables';
import type { Session } from '$lib/server/api/iam/sessions/sessions.service';
import type { Hono } from 'hono';
import type { PinoLogger } from 'hono-pino';
import type { Promisify, RateLimitInfo } from 'hono-rate-limiter';
import type { User } from 'lucia';
// export type AppOpenAPI = OpenAPIHono<AppBindings>;
export type AppOpenAPI = Hono<AppBindings>;
export type AppBindings = {
Variables: {
logger: PinoLogger;
session: Session | null;
user: User | null;
rateLimit: RateLimitInfo;
rateLimitStore: {
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
resetKey: (key: string) => Promisify<void>;
};
};
Variables: {
logger: PinoLogger;
session: Session | null;
user: Users | null;
rateLimit: RateLimitInfo;
rateLimitStore: {
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
resetKey: (key: string) => Promisify<void>;
};
};
};
export type HonoTypes = {
Variables: {
logger: PinoLogger;
session: Session | null;
user: User | null;
rateLimit: RateLimitInfo;
rateLimitStore: {
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
resetKey: (key: string) => Promisify<void>;
};
};
Variables: {
logger: PinoLogger;
session: Session | null;
user: Users | null;
rateLimit: RateLimitInfo;
rateLimitStore: {
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
resetKey: (key: string) => Promisify<void>;
};
};
};

View file

@ -1,63 +1,62 @@
import {config} from '$lib/server/api/common/config';
import { config } from '$lib/server/api/common/config';
import env from '$lib/server/api/common/env';
import type {Context} from 'hono';
import {setCookie} from 'hono/cookie';
import type {CookieOptions} from 'hono/utils/cookie';
import {TimeSpan} from 'oslo';
import type { Context } from 'hono';
import { setCookie } from 'hono/cookie';
import type { CookieOptions } from 'hono/utils/cookie';
export const cookieMaxAge = 60 * 60 * 24 * 30;
export const cookieExpiresMilliseconds = new TimeSpan(2, 'w').milliseconds();
export const cookieExpiresMilliseconds = cookieMaxAge * 1000; // new TimeSpan(2, 'w').milliseconds();
export const cookieExpiresAt = new Date(Date.now() + cookieExpiresMilliseconds);
export const halfCookieExpiresMilliseconds = cookieExpiresMilliseconds / 2;
export const halfCookieExpiresAt = new Date(Date.now() + halfCookieExpiresMilliseconds);
export const cookieName = 'session';
export type SessionCookie = {
name: string;
value: string;
attributes: CookieOptions;
name: string;
value: string;
attributes: CookieOptions;
};
export function createSessionTokenCookie(token: string, expiresAt: Date): SessionCookie {
return {
name: cookieName,
value: token,
attributes: {
path: '/',
maxAge: cookieMaxAge,
domain: env.DOMAIN,
sameSite: 'lax',
secure: config.isProduction,
httpOnly: true,
expires: expiresAt,
},
};
return {
name: cookieName,
value: token,
attributes: {
path: '/',
maxAge: cookieMaxAge,
domain: env.DOMAIN,
sameSite: 'lax',
secure: config.isProduction,
httpOnly: true,
expires: expiresAt,
},
};
}
export function createBlankSessionTokenCookie(): SessionCookie {
return {
name: cookieName,
value: '',
attributes: {
path: '/',
maxAge: 0,
domain: env.DOMAIN,
sameSite: 'lax',
secure: config.isProduction,
httpOnly: true,
expires: new Date(0),
},
};
return {
name: cookieName,
value: '',
attributes: {
path: '/',
maxAge: 0,
domain: env.DOMAIN,
sameSite: 'lax',
secure: config.isProduction,
httpOnly: true,
expires: new Date(0),
},
};
}
export function setSessionCookie(c: Context, sessionCookie: SessionCookie) {
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes?.maxAge,
domain: sessionCookie.attributes.domain,
sameSite: sessionCookie.attributes.sameSite as undefined,
secure: sessionCookie.attributes.secure,
httpOnly: sessionCookie.attributes.httpOnly,
expires: sessionCookie.attributes.expires,
});
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes?.maxAge,
domain: sessionCookie.attributes.domain,
sameSite: sessionCookie.attributes.sameSite as undefined,
secure: sessionCookie.attributes.secure,
httpOnly: sessionCookie.attributes.httpOnly,
expires: sessionCookie.attributes.expires,
});
}

View file

@ -1,4 +1,4 @@
import { customAlphabet } from "nanoid";
import { customAlphabet } from 'nanoid';
// generateId is a function that returns a new unique identifier.
// ~4 million years or 30 trillion IDs needed, in order to have a 1% probability of at least one collision.
@ -8,7 +8,7 @@ import { customAlphabet } from "nanoid";
// All hail king roomba, the first of his name, the unclean, king of the dust bunnies and the first allergens, lord of the seven corners, and protector of the realm.
// https://zelark.github.io/nano-id-cc/
export function generateId(length = 16, alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") {
const nanoId = customAlphabet(alphabet, length);
return nanoId();
export function generateId(length = 16, alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') {
const nanoId = customAlphabet(alphabet, length);
return nanoId();
}

View file

@ -0,0 +1,53 @@
import { customType, timestamp } from 'drizzle-orm/pg-core';
import { NotFound } from './exceptions';
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
// get the first element of an array or return null
export const takeFirst = <T>(values: T[]): T | null => {
return values.shift() || null;
};
// get the first element of an array or throw a 404 error
export const takeFirstOrThrow = <T>(values: T[]): T => {
const value = values.shift();
if (!value) throw NotFound('The requested resource was not found.');
return value;
};
/* -------------------------------------------------------------------------- */
/* Tables */
/* -------------------------------------------------------------------------- */
// custom type for citext
export const citext = customType<{ data: string }>({
dataType() {
return 'citext';
},
});
// custom type for generating an id
export const id = customType<{ data: string }>({
dataType() {
return 'text';
},
});
// timestamps for created_at and updated_at
export const timestamps = {
createdAt: timestamp('created_at', {
mode: 'date',
withTimezone: true,
})
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', {
mode: 'date',
withTimezone: true,
})
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
};

View file

@ -1,40 +1,40 @@
import {StatusCodes} from '$lib/constants/status-codes';
import {HTTPException} from 'hono/http-exception';
import { StatusCodes } from '$lib/utils/status-codes';
import { HTTPException } from 'hono/http-exception';
import * as HttpStatusPhrases from 'stoker/http-status-phrases';
import {createMessageObjectSchema} from 'stoker/openapi/schemas';
import { createMessageObjectSchema } from 'stoker/openapi/schemas';
export function TooManyRequests(message = 'Too many requests') {
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message });
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message });
}
export const tooManyRequestsSchema = createMessageObjectSchema(HttpStatusPhrases.TOO_MANY_REQUESTS);
export function Forbidden(message = 'Forbidden') {
return new HTTPException(StatusCodes.FORBIDDEN, { message });
return new HTTPException(StatusCodes.FORBIDDEN, { message });
}
export const forbiddenSchema = createMessageObjectSchema(HttpStatusPhrases.FORBIDDEN);
export function Unauthorized(message = 'Unauthorized') {
return new HTTPException(StatusCodes.UNAUTHORIZED, { message });
return new HTTPException(StatusCodes.UNAUTHORIZED, { message });
}
export const unauthorizedSchema = createMessageObjectSchema(HttpStatusPhrases.UNAUTHORIZED);
export function NotFound(message = 'Not Found') {
return new HTTPException(StatusCodes.NOT_FOUND, { message });
return new HTTPException(StatusCodes.NOT_FOUND, { message });
}
export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND);
export function BadRequest(message = 'Bad Request') {
return new HTTPException(StatusCodes.BAD_REQUEST, { message });
return new HTTPException(StatusCodes.BAD_REQUEST, { message });
}
export const badRequestSchema = createMessageObjectSchema(HttpStatusPhrases.BAD_REQUEST);
export function InternalError(message = 'Internal Error') {
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message });
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message });
}
export const internalErrorSchema = createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR);

View file

@ -0,0 +1,20 @@
import { Hono } from 'hono';
import type { SessionDto } from '../../iam/sessions/dtos/session.dto';
import type { PinoLogger } from 'hono-pino';
export type AppOpenAPI = Hono<HonoEnv>;
export type HonoEnv = {
Variables: {
logger: PinoLogger;
session: SessionDto | null;
browserSessionId: string;
requestId: string;
};
};
export function createHono() {
return new Hono<HonoEnv>({
strict: false,
});
}

View file

@ -1,70 +1,42 @@
import {apiReference} from '@scalar/hono-api-reference';
import type {AppOpenAPI} from '$lib/server/api/common/types/hono';
// import { createOpenApiDocument } from 'hono-zod-openapi';
import {createOpenApiDocument} from 'hono-zod-openapi';
import { apiReference } from '@scalar/hono-api-reference';
import { createOpenApiDocument } from 'hono-zod-openapi';
import packageJSON from '../../../../package.json';
// export default function configureOpenAPI(app: AppOpenAPI) {
// app.doc('/doc', {
// openapi: '3.0.0',
// info: {
// title: 'Bored Game API',
// description: 'Bored Game API',
// version: packageJSON.version,
// },
// });
//
// app.get(
// '/reference',
// apiReference({
// theme: 'kepler',
// layout: 'classic',
// defaultHttpClient: {
// targetKey: 'javascript',
// clientKey: 'fetch',
// },
// spec: {
// url: '/api/doc',
// },
// }),
// );
// }
import type { AppOpenAPI } from './common/utils/hono';
export default function configureOpenAPI(app: AppOpenAPI) {
createOpenApiDocument(app, {
info: {
title: 'Bored Game API',
description: 'Bored Game API',
version: packageJSON.version,
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
cookieAuth: {
type: 'apiKey',
name: 'session',
in: 'cookie',
}
},
},
});
createOpenApiDocument(app, {
info: {
title: 'Bored Game API',
description: 'Bored Game API',
version: packageJSON.version,
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
cookieAuth: {
type: 'apiKey',
name: 'session',
in: 'cookie',
},
},
},
});
app.get(
'/reference',
apiReference({
theme: 'kepler',
layout: 'classic',
defaultHttpClient: {
targetKey: 'javascript',
clientKey: 'fetch',
},
spec: {
url: '/api/doc',
},
}),
);
app.get(
'/reference',
apiReference({
theme: 'kepler',
layout: 'classic',
defaultHttpClient: {
targetKey: 'javascript',
clientKey: 'fetch',
},
spec: {
url: '/api/doc',
},
}),
);
}

View file

@ -1,68 +0,0 @@
import {StatusCodes} from '$lib/constants/status-codes';
import {unauthorizedSchema} from '$lib/server/api/common/exceptions';
import cuidParamsSchema from '$lib/server/api/common/openapi/cuidParamsSchema';
import {z} from '@hono/zod-openapi';
import {createErrorSchema} from 'stoker/openapi/schemas';
import {taggedAuthRoute} from '../common/openapi/create-auth-route';
import {selectCollectionSchema} from '../databases/postgres/tables';
const tag = 'Collection';
export const allCollections = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectCollectionSchema,
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const numberOfCollections = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'User profile',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const getCollectionByCUID = taggedAuthRoute(tag, {
request: {
param: {
schema: cuidParamsSchema,
example: { cuid: 'z6uiuc9qz82xjf5dexc5kr2d' },
},
},
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectCollectionSchema,
mediaType: 'application/json',
},
[StatusCodes.NOT_FOUND]: {
description: 'The collection does not exist',
schema: z.object({ message: z.string() }).openapi({
example: {
message: 'The collection does not exist',
},
}),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});

View file

@ -1,145 +0,0 @@
import {StatusCodes} from '$lib/constants/status-codes';
import {unauthorizedSchema} from '$lib/server/api/common/exceptions';
import {selectUserSchema} from '$lib/server/api/databases/postgres/tables/users.table';
import {updateProfileDto} from '$lib/server/api/dtos/update-profile.dto';
import {createErrorSchema} from 'stoker/openapi/schemas';
import {taggedAuthRoute} from '../common/openapi/create-auth-route';
import {changePasswordDto} from '../dtos/change-password.dto';
import {verifyPasswordDto} from '../dtos/verify-password.dto';
const tag = 'IAM';
export const iam = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectUserSchema,
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const updateProfile = taggedAuthRoute(tag, {
request: {
json: updateProfileDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Updated User',
schema: selectUserSchema,
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(updateProfileDto),
mediaType: 'application/json',
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'Username already in use',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const verifyPassword = taggedAuthRoute(tag, {
request: {
json: verifyPasswordDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Password verified',
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(verifyPasswordDto),
mediaType: 'application/json',
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'Incorrect password',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const updatePassword = taggedAuthRoute(tag, {
request: {
json: changePasswordDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Password updated',
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(changePasswordDto),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
[StatusCodes.FORBIDDEN]: {
description: 'Incorrect password',
mediaType: 'application/json',
},
[StatusCodes.INTERNAL_SERVER_ERROR]: {
description: 'Error updating password',
mediaType: 'application/json',
},
},
});
export const updateEmail = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'Email updated',
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(changePasswordDto),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
[StatusCodes.FORBIDDEN]: {
description: 'Cannot change email address',
mediaType: 'application/json',
},
},
});
export const logout = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'Logged out',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});

View file

@ -1,21 +0,0 @@
import {defineOpenApiOperation} from "hono-zod-openapi";
import {StatusCodes} from '$lib/constants/status-codes';
import {signinUsernameDto} from "../dtos/signin-username.dto";
import {createErrorSchema} from "stoker/openapi/schemas";
export const signinUsername = defineOpenApiOperation({
tags: ['Login'],
summary: 'Sign in with username',
description: 'Sign in with username',
responses: {
[StatusCodes.OK]: {
description: 'Sign in with username',
schema: signinUsernameDto,
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'The validation error(s)',
schema: createErrorSchema(signinUsernameDto),
mediaType: 'application/json',
}
}
});

View file

@ -1,125 +0,0 @@
import {Controller} from '$lib/server/api/common/types/controller';
import type {OAuthUser} from '$lib/server/api/common/types/oauth';
import {cookieExpiresAt, createSessionTokenCookie, setSessionCookie} from '$lib/server/api/common/utils/cookies';
import {OAuthService} from '$lib/server/api/services/oauth.service';
import {SessionsService} from '$lib/server/api/services/sessions.service';
import {github, google} from '$lib/server/auth';
import {OAuth2RequestError} from 'arctic';
import {getCookie} from 'hono/cookie';
import { injectable, inject } from "@needle-di/core";
@injectable()
export class OAuthController extends Controller {
constructor(
private oauthService = inject(OAuthService),
private sessionsService = inject(SessionsService),
) {
super();
}
routes() {
return this.controller
.get('/github', async (c) => {
try {
const code = c.req.query('code')?.toString() ?? null;
const state = c.req.query('state')?.toString() ?? null;
const storedState = getCookie(c).github_oauth_state ?? null;
if (!code || !state || !storedState || state !== storedState) {
return c.body(null, 400);
}
const tokens = await github.validateAuthorizationCode(code);
const githubUserResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const githubUser: GitHubUser = await githubUserResponse.json();
const oAuthUser: OAuthUser = {
sub: `${githubUser.id}`,
username: githubUser.login,
email: undefined,
};
const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'github');
const sessionToken = this.sessionsService.generateSessionToken();
const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false);
const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
setSessionCookie(c, sessionCookie);
return c.json({ message: 'ok' });
} catch (error) {
console.error(error);
// the specific error message depends on the provider
if (error instanceof OAuth2RequestError) {
// invalid code
return c.body(null, 400);
}
return c.body(null, 500);
}
})
.get('/google', async (c) => {
try {
const code = c.req.query('code')?.toString() ?? null;
const state = c.req.query('state')?.toString() ?? null;
const storedState = getCookie(c).google_oauth_state ?? null;
const storedCodeVerifier = getCookie(c).google_oauth_code_verifier ?? null;
if (!code || !storedState || !storedCodeVerifier || state !== storedState) {
return c.body(null, 400);
}
const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier);
const googleUserResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const googleUser: GoogleUser = await googleUserResponse.json();
const oAuthUser: OAuthUser = {
sub: googleUser.sub,
given_name: googleUser.given_name,
family_name: googleUser.family_name,
picture: googleUser.picture,
username: googleUser.email,
email: googleUser.email,
email_verified: googleUser.email_verified,
};
const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'google');
const sessionToken = this.sessionsService.generateSessionToken();
const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false);
const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
setSessionCookie(c, sessionCookie);
return c.json({ message: 'ok' });
} catch (error) {
console.error(error);
// the specific error message depends on the provider
if (error instanceof OAuth2RequestError) {
// invalid code
return c.body(null, 400);
}
return c.body(null, 500);
}
});
}
}
interface GitHubUser {
id: number;
login: string;
}
interface GoogleUser {
sub: string;
name: string;
given_name: string;
family_name: string;
picture: string;
email: string;
email_verified: boolean;
}

View file

@ -1,44 +0,0 @@
import 'reflect-metadata';
import {Controller} from '$lib/server/api/common/types/controller';
import {signupUsernameEmailDto} from '$lib/server/api/dtos/signup-username-email.dto';
import {limiter} from '$lib/server/api/middleware/rate-limiter.middleware';
import {LoginRequestsService} from '$lib/server/api/services/loginrequest.service';
import {SessionsService} from '$lib/server/api/services/sessions.service';
import {UsersService} from '$lib/server/api/services/users.service';
import {zValidator} from '@hono/zod-validator';
import {inject, injectable} from '@needle-di/core';
import {cookieExpiresAt, createSessionTokenCookie, setSessionCookie} from "$lib/server/api/common/utils/cookies";
@injectable()
export class SignupController extends Controller {
constructor(
private usersService = inject(UsersService),
private loginRequestService = inject(LoginRequestsService),
private sessionsService = inject(SessionsService),
) {
super();
}
routes() {
return this.controller.post('/', zValidator('json', signupUsernameEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { firstName, lastName, email, username, password, confirm_password } = await c.req.valid('json');
const existingUser = await this.usersService.findOneByUsername(username);
if (existingUser) {
return c.body('User already exists', 400);
}
const user = await this.usersService.create({ firstName, lastName, email, username, password, confirm_password });
if (!user) {
return c.body('Failed to create user', 500);
}
const session = await this.loginRequestService.createUserSession(user.id, c.req, false, false);
const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
console.log('set cookie', sessionCookie);
setSessionCookie(c, sessionCookie);
return c.json({ message: 'ok' });
});
}
}

View file

@ -1,30 +0,0 @@
import { Controller } from '$lib/server/api/common/types/controller';
import { UsersService } from '$lib/server/api/services/users.service';
import { inject, injectable } from '@needle-di/core';
import { requireFullAuth, requireTempAuth } from '../middleware/require-auth.middleware';
@injectable()
export class UserController extends Controller {
constructor(private usersService = inject(UsersService)) {
super();
}
routes() {
return this.controller
.get('/', requireTempAuth, async (c) => {
const session = c.var.session;
const user = session ? await this.usersService.findOneById(session.userId) : null;
return c.json({ user, session });
})
.get('/:id', requireFullAuth, async (c) => {
const id = c.req.param('id');
const user = await this.usersService.findOneById(id);
return c.json({ user });
})
.get('/username/:userName', requireFullAuth, async (c) => {
const userName = c.req.param('userName');
const user = await this.usersService.findOneByUsername(userName);
return c.json({ user });
});
}
}

View file

@ -0,0 +1,31 @@
import { ConfigService } from '$lib/server/api/common/configs/config.service';
import { inject, injectable } from '@needle-di/core';
import { type NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './tables';
@injectable()
export class DrizzleService {
pool: pg.Pool;
db: NodePgDatabase<typeof schema>;
readonly schema: typeof schema = schema;
constructor(private configService = inject(ConfigService)) {
const pool = new pg.Pool({
user: this.configService.envs.DATABASE_USER,
password: this.configService.envs.DATABASE_PASSWORD,
host: this.configService.envs.DATABASE_HOST,
port: Number(this.configService.envs.DATABASE_PORT).valueOf(),
database: this.configService.envs.DATABASE_DB,
ssl: false,
max: this.configService.envs.DB_MIGRATING || this.configService.envs.DB_SEEDING ? 1 : undefined,
});
this.pool = pool;
this.db = drizzle({
client: pool,
casing: 'snake_case',
schema,
logger: this.configService.envs.ENV !== 'prod',
});
}
}

View file

@ -1,20 +1,20 @@
import 'dotenv/config';
import {migrate} from 'drizzle-orm/node-postgres/migrator';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import config from '../../../../../../drizzle.config';
import env from '../../common/env';
import {DrizzleService} from '../../services/drizzle.service';
import { DrizzleService } from './drizzle.service';
const drizzleService = new DrizzleService();
if (!config.out) {
console.error('No migrations folder specified in drizzle.config.ts');
process.exit();
console.error('No migrations folder specified in drizzle.config.ts');
process.exit();
}
if (!env.DB_MIGRATING) {
throw new Error('You must set DB_MIGRATING to "true" when running migrations.');
throw new Error('You must set DB_MIGRATING to "true" when running migrations.');
}
await migrate(drizzleService.db, { migrationsFolder: config.out });
console.log('Migrations complete');
await drizzleService.dispose();
await drizzleService.pool.end();
process.exit();

View file

@ -1,53 +1,53 @@
import {getTableName, sql, type Table} from 'drizzle-orm';
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
import { type Table, getTableName, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import env from '../../common/env';
import {DrizzleService} from '../../services/drizzle.service';
import { DrizzleService } from './drizzle.service';
import * as seeds from './seeds';
import * as schema from './tables';
const drizzleService = new DrizzleService();
if (!env.DB_SEEDING) {
throw new Error('You must set DB_SEEDING to "true" when running seeds');
throw new Error('You must set DB_SEEDING to "true" when running seeds');
}
async function resetTable(db: NodePgDatabase<typeof schema>, table: Table) {
return db.execute(sql.raw(`TRUNCATE TABLE ${getTableName(table)} RESTART IDENTITY CASCADE`));
return db.execute(sql.raw(`TRUNCATE TABLE ${getTableName(table)} RESTART IDENTITY CASCADE`));
}
for (const table of [
schema.categoriesTable,
schema.categoriesToExternalIdsTable,
schema.categories_to_games_table,
schema.collection_items,
schema.collections,
schema.credentialsTable,
schema.expansionsTable,
schema.externalIdsTable,
schema.federatedIdentityTable,
schema.gamesTable,
schema.gamesToExternalIdsTable,
schema.mechanicsTable,
schema.mechanicsToExternalIdsTable,
schema.mechanics_to_games,
schema.password_reset_tokens,
schema.publishersTable,
schema.publishersToExternalIdsTable,
schema.publishers_to_games,
schema.recoveryCodesTable,
schema.rolesTable,
schema.twoFactorTable,
schema.user_roles,
schema.usersTable,
schema.wishlist_items,
schema.wishlistsTable,
schema.categoriesTable,
schema.categoriesToExternalIdsTable,
schema.categories_to_games_table,
schema.collection_items,
schema.collections,
schema.credentialsTable,
schema.expansionsTable,
schema.externalIdsTable,
schema.federatedIdentityTable,
schema.gamesTable,
schema.gamesToExternalIdsTable,
schema.mechanicsTable,
schema.mechanicsToExternalIdsTable,
schema.mechanics_to_games,
schema.password_reset_tokens,
schema.publishersTable,
schema.publishersToExternalIdsTable,
schema.publishers_to_games,
schema.recoveryCodesTable,
schema.rolesTable,
schema.twoFactorTable,
schema.user_roles,
schema.usersTable,
schema.wishlist_items,
schema.wishlistsTable,
]) {
// await db.delete(table); // clear tables without truncating / resetting ids
await resetTable(drizzleService.db, table);
// await db.delete(table); // clear tables without truncating / resetting ids
await resetTable(drizzleService.db, table);
}
await seeds.roles(drizzleService.db);
await seeds.users(drizzleService.db);
await drizzleService.dispose();
await drizzleService.pool.end();
process.exit();

View file

@ -1,89 +1,89 @@
import {eq} from 'drizzle-orm';
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
import {HashingService} from '../../../services/hashing.service';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { HashingService } from '../../../common/services/hashing.service';
import * as schema from '../tables';
import users from './data/users.json';
type JsonRole = {
name: string;
primary: boolean;
name: string;
primary: boolean;
};
export default async function seed(db: NodePgDatabase<typeof schema>) {
const hashingService = new HashingService();
const adminRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'admin'));
const userRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'user'));
const hashingService = new HashingService();
const adminRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'admin'));
const userRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'user'));
const adminUser = await db
.insert(schema.usersTable)
.values({
username: `${process.env.ADMIN_USERNAME}`,
email: '',
first_name: 'Brad',
last_name: 'S',
verified: true,
})
.returning()
.onConflictDoNothing();
const adminUser = await db
.insert(schema.usersTable)
.values({
username: `${process.env.ADMIN_USERNAME}`,
email: '',
first_name: 'Brad',
last_name: 'S',
verified: true,
})
.returning()
.onConflictDoNothing();
await db.insert(schema.credentialsTable).values({
user_id: adminUser[0].id,
type: schema.CredentialsType.PASSWORD,
secret_data: await hashingService.hash(`${process.env.ADMIN_PASSWORD}`),
});
await db.insert(schema.credentialsTable).values({
user_id: adminUser[0].id,
type: schema.CredentialsType.PASSWORD,
secret_data: await hashingService.hash(`${process.env.ADMIN_PASSWORD}`),
});
await db.insert(schema.collections).values({ user_id: adminUser[0].id }).onConflictDoNothing();
await db.insert(schema.collections).values({ user_id: adminUser[0].id }).onConflictDoNothing();
await db.insert(schema.wishlistsTable).values({ user_id: adminUser[0].id }).onConflictDoNothing();
await db.insert(schema.wishlistsTable).values({ user_id: adminUser[0].id }).onConflictDoNothing();
await db
.insert(schema.user_roles)
.values({
user_id: adminUser[0].id,
role_id: adminRole[0].id,
primary: true,
})
.onConflictDoNothing();
await db
.insert(schema.user_roles)
.values({
user_id: adminUser[0].id,
role_id: adminRole[0].id,
primary: true,
})
.onConflictDoNothing();
await db
.insert(schema.user_roles)
.values({
user_id: adminUser[0].id,
role_id: userRole[0].id,
primary: false,
})
.onConflictDoNothing();
await db
.insert(schema.user_roles)
.values({
user_id: adminUser[0].id,
role_id: userRole[0].id,
primary: false,
})
.onConflictDoNothing();
await Promise.all(
users.map(async (user) => {
const [insertedUser] = await db
.insert(schema.usersTable)
.values({
...user,
})
.returning();
await db.insert(schema.credentialsTable).values({
user_id: insertedUser?.id,
type: schema.CredentialsType.PASSWORD,
secret_data: await hashingService.hash(user.password),
});
await db.insert(schema.collections).values({ user_id: insertedUser?.id });
await db.insert(schema.wishlistsTable).values({ user_id: insertedUser?.id });
await Promise.all(
user.roles.map(async (role: JsonRole) => {
const foundRole = await db.query.rolesTable.findFirst({
where: eq(schema.rolesTable.name, role.name),
});
if (!foundRole) {
throw new Error('Role not found');
}
await db.insert(schema.user_roles).values({
user_id: insertedUser?.id,
role_id: foundRole?.id,
primary: role?.primary,
});
}),
);
}),
);
await Promise.all(
users.map(async (user) => {
const [insertedUser] = await db
.insert(schema.usersTable)
.values({
...user,
})
.returning();
await db.insert(schema.credentialsTable).values({
user_id: insertedUser?.id,
type: schema.CredentialsType.PASSWORD,
secret_data: await hashingService.hash(user.password),
});
await db.insert(schema.collections).values({ user_id: insertedUser?.id });
await db.insert(schema.wishlistsTable).values({ user_id: insertedUser?.id });
await Promise.all(
user.roles.map(async (role: JsonRole) => {
const foundRole = await db.query.rolesTable.findFirst({
where: eq(schema.rolesTable.name, role.name),
});
if (!foundRole) {
throw new Error('Role not found');
}
await db.insert(schema.user_roles).values({
user_id: insertedUser?.id,
role_id: foundRole?.id,
primary: role?.primary,
});
}),
);
}),
);
}

View file

@ -1,32 +1,55 @@
import {createId as cuid2} from '@paralleldrive/cuid2';
import {type InferSelectModel, relations} from 'drizzle-orm';
import {boolean, pgTable, text, uuid} from 'drizzle-orm/pg-core';
import {createSelectSchema} from 'drizzle-zod';
import {timestamps} from '../../../common/utils/table';
import {user_roles} from './userRoles.table';
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations, getTableColumns } from 'drizzle-orm';
import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { createSelectSchema } from 'drizzle-zod';
import { timestamps } from '../../../common/utils/table';
import { user_roles } from './userRoles.table';
/* -------------------------------------------------------------------------- */
/* Table */
/* -------------------------------------------------------------------------- */
export const usersTable = pgTable('users', {
id: uuid().primaryKey().defaultRandom(),
cuid: text()
.unique()
.$defaultFn(() => cuid2()),
username: text().unique(),
email: text().unique(),
first_name: text(),
last_name: text(),
verified: boolean().default(false),
receive_email: boolean().default(false),
email_verified: boolean().default(false),
picture: text(),
mfa_enabled: boolean().notNull().default(false),
theme: text().default('system'),
...timestamps,
id: uuid().primaryKey().defaultRandom(),
cuid: text()
.unique()
.$defaultFn(() => cuid2()),
username: text().unique(),
email: text().unique(),
first_name: text(),
last_name: text(),
verified: boolean().default(false),
receive_email: boolean().default(false),
email_verified: boolean().default(false),
picture: text(),
mfa_enabled: boolean().notNull().default(false),
theme: text().default('system'),
...timestamps,
});
/* -------------------------------------------------------------------------- */
/* Relations */
/* -------------------------------------------------------------------------- */
export const userRelations = relations(usersTable, ({ many }) => ({
user_roles: many(user_roles),
user_roles: many(user_roles),
}));
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export const selectUserSchema = createSelectSchema(usersTable);
export type Users = InferSelectModel<typeof usersTable>;
export type UserWithRelations = Users & {};
const userColumns = getTableColumns(usersTable);
export const publicUserColumns = {
id: userColumns.id,
cuid: userColumns.cuid,
username: userColumns.username,
first_name: userColumns.first_name,
last_name: userColumns.last_name,
picture: userColumns.picture,
theme: userColumns.theme,
...timestamps,
};

View file

@ -0,0 +1,35 @@
import { config } from '$lib/server/api/common/config';
import { injectable } from '@needle-di/core';
import { Redis } from 'ioredis';
@injectable()
export class RedisService {
readonly redis: Redis;
constructor() {
this.redis = new Redis(config.redis.url, {
maxRetriesPerRequest: null,
});
}
async get(data: { prefix: string; key: string }): Promise<string | null> {
return this.redis.get(`${data.prefix}:${data.key}`);
}
async set(data: { prefix: string; key: string; value: string }): Promise<void> {
await this.redis.set(`${data.prefix}:${data.key}`, data.value);
}
async delete(data: { prefix: string; key: string }): Promise<void> {
await this.redis.del(`${data.prefix}:${data.key}`);
}
async setWithExpiry(data: {
prefix: string;
key: string;
value: string;
expiry: number;
}): Promise<void> {
await this.redis.set(`${data.prefix}:${data.key}`, data.value, 'EXAT', Math.floor(data.expiry));
}
}

View file

@ -1,20 +1,20 @@
import { StatusCodes } from '$lib/constants/status-codes';
import { limiter } from '$lib/server/api/common/middleware/rate-limit.middleware';
import { requireFullAuth, requireTempAuth } from '$lib/server/api/common/middleware/require-auth.middleware';
import { Controller } from '$lib/server/api/common/types/controller';
import { createBlankSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies';
import { changePasswordDto } from '$lib/server/api/dtos/change-password.dto';
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
import { verifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto';
import { limiter } from '$lib/server/api/middleware/rate-limiter.middleware';
import { IamService } from '$lib/server/api/services/iam.service';
import { LoginRequestsService } from '$lib/server/api/services/loginrequest.service';
import { SessionsService } from '$lib/server/api/services/sessions.service';
import { IamService } from '$lib/server/api/iam/iam.service';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { LoginRequestsService } from '$lib/server/api/login/loginrequest.service';
import { StatusCodes } from '$lib/utils/status-codes';
import { zValidator } from '@hono/zod-validator';
import { inject, injectable } from '@needle-di/core';
import { openApi } from 'hono-zod-openapi';
import { injectable, inject } from '@needle-di/core';
import { requireFullAuth, requireTempAuth } from '../middleware/require-auth.middleware';
import { UsersRepository } from '../users/users.repository';
import { iam, logout, updateEmail, updatePassword, updateProfile, verifyPassword } from './iam.routes';
import { UsersRepository } from '../repositories/users.repository';
@injectable()
export class IamController extends Controller {

View file

@ -0,0 +1,145 @@
import { unauthorizedSchema } from '$lib/server/api/common/utils/exceptions';
import { selectUserSchema } from '$lib/server/api/databases/postgres/tables/users.table';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
import { StatusCodes } from '$lib/utils/status-codes';
import { createErrorSchema } from 'stoker/openapi/schemas';
import { taggedAuthRoute } from '../common/openapi/create-auth-route';
import { changePasswordDto } from '../dtos/change-password.dto';
import { verifyPasswordDto } from '../dtos/verify-password.dto';
const tag = 'IAM';
export const iam = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectUserSchema,
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const updateProfile = taggedAuthRoute(tag, {
request: {
json: updateProfileDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Updated User',
schema: selectUserSchema,
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(updateProfileDto),
mediaType: 'application/json',
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'Username already in use',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const verifyPassword = taggedAuthRoute(tag, {
request: {
json: verifyPasswordDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Password verified',
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(verifyPasswordDto),
mediaType: 'application/json',
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'Incorrect password',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const updatePassword = taggedAuthRoute(tag, {
request: {
json: changePasswordDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Password updated',
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(changePasswordDto),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
[StatusCodes.FORBIDDEN]: {
description: 'Incorrect password',
mediaType: 'application/json',
},
[StatusCodes.INTERNAL_SERVER_ERROR]: {
description: 'Error updating password',
mediaType: 'application/json',
},
},
});
export const updateEmail = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'Email updated',
mediaType: 'application/json',
},
[StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)',
schema: createErrorSchema(changePasswordDto),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
[StatusCodes.FORBIDDEN]: {
description: 'Cannot change email address',
mediaType: 'application/json',
},
},
});
export const logout = taggedAuthRoute(tag, {
responses: {
[StatusCodes.OK]: {
description: 'Logged out',
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});

View file

@ -2,8 +2,8 @@ import type { ChangePasswordDto } from '$lib/server/api/dtos/change-password.dto
import type { UpdateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import type { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
import type { VerifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto';
import { SessionsService } from '$lib/server/api/services/sessions.service';
import { UsersService } from '$lib/server/api/services/users.service';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { UsersService } from '$lib/server/api/users/users.service';
import { inject, injectable } from '@needle-di/core';
@injectable()

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
export const createSessionDto = z.object({
id: z.string(),
userId: z.string(),
createdAt: z.date({ coerce: true }),
expiresAt: z.date({ coerce: true }),
ipCountry: z.string(),
ipAddress: z.string(),
twoFactorEnabled: z.boolean(),
twoFactorVerified: z.boolean(),
});
export type CreateSessionDto = z.infer<typeof createSessionDto>;

View file

@ -0,0 +1,13 @@
import { z } from 'zod';
export const sessionDto = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.date(),
createdAt: z.date(),
fresh: z.boolean(),
twoFactorEnabled: z.boolean(),
twoFactorVerified: z.boolean(),
});
export type SessionDto = z.infer<typeof sessionDto>;

View file

@ -0,0 +1,31 @@
import { RedisRepository } from '$lib/server/api/common/factories/redis-repository.factory';
import { type CreateSessionDto, createSessionDto } from '$lib/server/api/iam/sessions/dtos/create-session-dto';
import { injectable } from '@needle-di/core';
@injectable()
export class SessionsRepository extends RedisRepository<'session'> {
async get(id: string): Promise<CreateSessionDto | null> {
const response = await this.redis.get({ prefix: this.prefix, key: id });
if (!response) return null;
return createSessionDto.parse(JSON.parse(response));
}
async findOneById(id: string): Promise<CreateSessionDto | null> {
const response = await this.redis.get({ prefix: this.prefix, key: id });
if (!response) return null;
return createSessionDto.parse(JSON.parse(response));
}
delete(id: string) {
return this.redis.delete({ prefix: this.prefix, key: id });
}
create(createSessionDto: CreateSessionDto) {
return this.redis.setWithExpiry({
prefix: this.prefix,
key: createSessionDto.id,
value: JSON.stringify(createSessionDto),
expiry: Math.floor(+createSessionDto.expiresAt / 1000),
});
}
}

View file

@ -0,0 +1,92 @@
import { ConfigService } from '$lib/server/api/common/configs/config.service';
import { RequestContextService } from '$lib/server/api/common/services/request-context.service';
import { generateId } from '$lib/server/api/common/utils/crypto';
import type { CreateSessionDto } from '$lib/server/api/iam/sessions/dtos/create-session-dto';
import type { SessionDto } from '$lib/server/api/iam/sessions/dtos/session.dto';
import { SessionsRepository } from '$lib/server/api/iam/sessions/sessions.repository';
import { UsersService } from '$lib/server/api/users/users.service';
import { inject, injectable } from '@needle-di/core';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
dayjs.extend(relativeTime);
@injectable()
export class SessionsService {
private readonly sessionCookieName = 'session';
constructor(
private sessionsRepository = inject(SessionsRepository),
private requestContextService = inject(RequestContextService),
private configService = inject(ConfigService),
private usersService = inject(UsersService),
) {}
setSessionCookie(session: SessionDto) {
return setSignedCookie(this.requestContextService.getContext(), this.sessionCookieName, session.id, this.configService.envs.SIGNING_SECRET, {
httpOnly: true,
sameSite: 'lax',
secure: this.configService.envs.ENV === 'prod',
path: '/',
expires: session.expiresAt,
});
}
async getSessionCookie(): Promise<string | null> {
const session = await getSignedCookie(this.requestContextService.getContext(), this.configService.envs.SIGNING_SECRET, this.sessionCookieName);
if (!session) return null;
return session;
}
deleteSessionCookie() {
return deleteCookie(this.requestContextService.getContext(), this.sessionCookieName);
}
async createSession(userId: string, twoFactorVerified = false, ipCountry = 'unknown', ipAddress = 'unknown'): Promise<SessionDto> {
const user = await this.usersService.findOneById(userId);
if (!user) throw new Error('User not found');
const session: CreateSessionDto = {
id: this.generateSessionToken(),
userId,
createdAt: dayjs().toDate(),
expiresAt: dayjs().add(30, 'day').toDate(),
ipCountry,
ipAddress,
twoFactorEnabled: user.mfa_enabled,
twoFactorVerified,
};
await this.sessionsRepository.create(session);
return { ...session, fresh: true };
}
async validateSession(sessionId: string): Promise<SessionDto | null> {
// Check if session exists
const existingSession = await this.sessionsRepository.get(sessionId);
// If session does not exist, return null
if (!existingSession) return null;
// If session exists, check if it should be extended
const shouldExtendSession = dayjs(existingSession.expiresAt).diff(Date.now(), 'day') < 15;
// If session should be extended, update the session in the database
if (shouldExtendSession) {
existingSession.expiresAt = dayjs().add(30, 'day').toDate();
await this.sessionsRepository.create({ ...existingSession });
return { ...existingSession, fresh: true };
}
return { ...existingSession, fresh: false };
}
async invalidateSession(sessionId: string): Promise<void> {
await this.sessionsRepository.delete(sessionId);
}
private generateSessionToken(): string {
return generateId();
}
}

View file

@ -1,51 +1,21 @@
import createApp from "$lib/server/api/common/create-app";
import configureOpenAPI from "$lib/server/api/configure-open-api";
import { CollectionController } from "$lib/server/api/controllers/collection.controller";
import { MfaController } from "$lib/server/api/controllers/mfa.controller";
import { OAuthController } from "$lib/server/api/controllers/oauth.controller";
import { SignupController } from "$lib/server/api/controllers/signup.controller";
import { UserController } from "$lib/server/api/controllers/user.controller";
import { WishlistController } from "$lib/server/api/controllers/wishlist.controller";
import { AuthCleanupJobs } from "$lib/server/api/jobs/auth-cleanup.job";
import { Container } from "@needle-di/core";
import { extendZodWithOpenApi } from "hono-zod-openapi";
import { hc } from "hono/client";
import { z } from "zod";
import { config } from "./common/config";
import { IamController } from "./controllers/iam.controller";
import { LoginController } from "./controllers/login.controller";
import { Container } from '@needle-di/core';
import { extendZodWithOpenApi } from 'hono-zod-openapi';
import { z } from 'zod';
import { ApplicationController } from './application.controller';
import { ApplicationModule } from './application.module';
extendZodWithOpenApi(z);
const container = new Container();
const applicationController = new Container().get(ApplicationController);
const applicationModule = new Container().get(ApplicationModule);
export const app = createApp();
/* ------------------------------ startServer ------------------------------ */
export function startServer() {
return applicationModule.start();
}
/* -------------------------------------------------------------------------- */
/* Routes */
/* -------------------------------------------------------------------------- */
const routes = app
.route("/me", container.get(IamController).routes())
.route("/user", container.get(UserController).routes())
.route("/login", container.get(LoginController).routes())
.route("/oauth", container.get(OAuthController).routes())
.route("/signup", container.get(SignupController).routes())
.route("/wishlists", container.get(WishlistController).routes())
.route("/collections", container.get(CollectionController).routes())
.route("/mfa", container.get(MfaController).routes())
.get("/", (c) => c.json({ message: "Server is healthy" }));
/* ----------------------------------- api ---------------------------------- */
export const routes = applicationController.registerControllers();
configureOpenAPI(app);
/* -------------------------------------------------------------------------- */
/* Cron Jobs */
/* -------------------------------------------------------------------------- */
container.get(AuthCleanupJobs).deleteStaleEmailVerificationRequests();
container.get(AuthCleanupJobs).deleteStaleLoginRequests();
/* -------------------------------------------------------------------------- */
/* Exports */
/* -------------------------------------------------------------------------- */
export const rpc = hc<typeof routes>(config.api.origin);
export type ApiClient = typeof rpc;
/* ---------------------------------- Types --------------------------------- */
export type ApiRoutes = typeof routes;

View file

@ -1,13 +1,13 @@
import { limiter } from '$lib/server/api/common/middleware/rate-limit.middleware';
import { Controller } from '$lib/server/api/common/types/controller';
import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies';
import { signinUsernameDto } from '$lib/server/api/dtos/signin-username.dto';
import { SessionsService } from '$lib/server/api/services/sessions.service';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { zValidator } from '@hono/zod-validator';
import { openApi } from 'hono-zod-openapi';
import { inject, injectable } from '@needle-di/core';
import { limiter } from '../middleware/rate-limiter.middleware';
import { LoginRequestsService } from '../services/loginrequest.service';
import { openApi } from 'hono-zod-openapi';
import { signinUsername } from './login.routes';
import { LoginRequestsService } from './loginrequest.service';
@injectable()
export class LoginController extends Controller {

View file

@ -0,0 +1,21 @@
import { StatusCodes } from '$lib/utils/status-codes';
import { defineOpenApiOperation } from 'hono-zod-openapi';
import { createErrorSchema } from 'stoker/openapi/schemas';
import { signinUsernameDto } from '../dtos/signin-username.dto';
export const signinUsername = defineOpenApiOperation({
tags: ['Login'],
summary: 'Sign in with username',
description: 'Sign in with username',
responses: {
[StatusCodes.OK]: {
description: 'Sign in with username',
schema: signinUsernameDto,
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'The validation error(s)',
schema: createErrorSchema(signinUsernameDto),
mediaType: 'application/json',
},
},
});

View file

@ -1,14 +1,14 @@
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import type { SigninUsernameDto } from '$lib/server/api/dtos/signin-username.dto';
import { SessionsService } from '$lib/server/api/services/sessions.service';
import type { HonoRequest } from 'hono';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { inject, injectable } from '@needle-di/core';
import { BadRequest, NotFound } from '../common/exceptions';
import type { HonoRequest } from 'hono';
import { BadRequest, NotFound } from '../common/utils/exceptions';
import type { Credentials } from '../databases/postgres/tables';
import { CredentialsRepository } from '../repositories/credentials.repository';
import { UsersRepository } from '../repositories/users.repository';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { DrizzleService } from '$lib/server/api/services/drizzle.service';
import { MailerService } from '../services/mailer.service';
import { TokensService } from '../services/tokens.service';
import { CredentialsRepository } from '../users/credentials.repository';
import { UsersRepository } from '../users/users.repository';
@injectable()
export class LoginRequestsService {

View file

@ -1,17 +1,17 @@
import { StatusCodes } from '$lib/constants/status-codes';
import { requireFullAuth, requireTempAuth } from '$lib/server/api/common/middleware/require-auth.middleware';
import { Controller } from '$lib/server/api/common/types/controller';
import { verifyTotpDto } from '$lib/server/api/dtos/verify-totp.dto';
import { RecoveryCodesService } from '$lib/server/api/services/recovery-codes.service';
import { TotpService } from '$lib/server/api/services/totp.service';
import { UsersService } from '$lib/server/api/services/users.service';
import { TotpService } from '$lib/server/api/mfa/totp.service';
import { RecoveryCodesService } from '$lib/server/api/users/recovery-codes.service';
import { UsersService } from '$lib/server/api/users/users.service';
import { StatusCodes } from '$lib/utils/status-codes';
import { zValidator } from '@hono/zod-validator';
import { inject, injectable } from '@needle-di/core';
import { CredentialsType } from '../databases/postgres/tables';
import { requireFullAuth, requireTempAuth } from '../middleware/require-auth.middleware';
import { createTwoFactorSchema } from '../dtos/create-totp.dto';
import { decodeBase64 } from '@oslojs/encoding';
import { LoginRequestsService } from '../services/loginrequest.service';
import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '../common/utils/cookies';
import { CredentialsType } from '../databases/postgres/tables';
import { createTwoFactorSchema } from '../dtos/create-totp.dto';
import { LoginRequestsService } from '../login/loginrequest.service';
@injectable()
export class MfaController extends Controller {

View file

@ -1,15 +1,15 @@
import { CredentialsRepository } from '$lib/server/api/repositories/credentials.repository';
import { inject, injectable } from '@needle-di/core';
import { decodeBase64, encodeBase64 } from "@oslojs/encoding";
import { decodeBase64, encodeBase64 } from '@oslojs/encoding';
import { generateTOTP, verifyTOTP } from '@oslojs/otp';
import { EncryptionService } from '../common/services/encryption.service';
import type { CredentialsType } from '../databases/postgres/tables';
import { EncryptionService } from './encryption.service';
import { CredentialsRepository } from '../users/credentials.repository';
@injectable()
export class TotpService {
constructor(
private credentialsRepository = inject(CredentialsRepository),
private encryptionService = inject(EncryptionService)
private encryptionService = inject(EncryptionService),
) {}
async findOneByUserId(userId: string) {

View file

@ -1,53 +0,0 @@
import {
cookieExpiresAt,
cookieName,
createBlankSessionTokenCookie,
createSessionTokenCookie,
type SessionCookie,
setSessionCookie,
} from '$lib/server/api/common/utils/cookies';
import {SessionsService} from '$lib/server/api/services/sessions.service';
import type {MiddlewareHandler} from 'hono';
import {getCookie} from 'hono/cookie';
import {createMiddleware} from 'hono/factory';
import {verifyRequestOrigin} from 'oslo/request';
import { Container } from '@needle-di/core';
import type {AppBindings} from '../common/types/hono';
// resolve dependencies from the container
const container = new Container();
const sessionService = container.get(SessionsService);
// CSRF protection middleware
export const verifyOrigin: MiddlewareHandler<AppBindings> = createMiddleware(async (c, next) => {
if (c.req.method === 'GET') {
return next();
}
const originHeader = c.req.header('Origin') ?? null;
const hostHeader = c.req.header('Host') ?? null;
if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) {
return c.body(null, 403);
}
return next();
});
export const validateAuthSession: MiddlewareHandler<AppBindings> = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, cookieName) ?? null;
if (!sessionId) {
c.set('user', null);
c.set('session', null);
return next();
}
const { session, user } = await sessionService.validateSessionToken(sessionId);
let sessionCookie: SessionCookie;
if (session !== null) {
sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
} else {
sessionCookie = createBlankSessionTokenCookie();
}
setSessionCookie(c, sessionCookie);
c.set('session', session);
c.set('user', user);
return next();
});

View file

@ -1,36 +0,0 @@
import {rateLimiter} from 'hono-rate-limiter';
import {RedisStore} from 'rate-limit-redis';
import { Container } from '@needle-di/core';
import type {AppBindings} from '../common/types/hono';
import {RedisService} from '../services/redis.service';
const container = new Container();
// resolve dependencies from the container
const { client } = container.get(RedisService);
export function limiter({
limit,
minutes,
key = '',
}: {
limit: number;
minutes: number;
key?: string;
}) {
return rateLimiter({
windowMs: minutes * 60 * 1000, // every x minutes
limit, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header
keyGenerator: (c) => {
const vars = c.var as AppBindings['Variables'];
const clientKey = vars.user?.id || c.req.header('x-forwarded-for');
const pathKey = key || c.req.routePath;
return `${clientKey}_${pathKey}`;
}, // Method to generate custom identifiers for clients.
// Redis store configuration
store: new RedisStore({
// @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis
sendCommand: (...args: string[]) => client.call(...args),
}) as any,
});
}

View file

@ -0,0 +1,125 @@
import { Controller } from '$lib/server/api/common/types/controller';
import type { OAuthUser } from '$lib/server/api/common/types/oauth';
import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { OAuthService } from '$lib/server/api/oauth/oauth.service';
import { github, google } from '$lib/server/auth';
import { inject, injectable } from '@needle-di/core';
import { OAuth2RequestError } from 'arctic';
import { getCookie } from 'hono/cookie';
@injectable()
export class OAuthController extends Controller {
constructor(
private oauthService = inject(OAuthService),
private sessionsService = inject(SessionsService),
) {
super();
}
routes() {
return this.controller
.get('/github', async (c) => {
try {
const code = c.req.query('code')?.toString() ?? null;
const state = c.req.query('state')?.toString() ?? null;
const storedState = getCookie(c).github_oauth_state ?? null;
if (!code || !state || !storedState || state !== storedState) {
return c.body(null, 400);
}
const tokens = await github.validateAuthorizationCode(code);
const githubUserResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const githubUser: GitHubUser = await githubUserResponse.json();
const oAuthUser: OAuthUser = {
sub: `${githubUser.id}`,
username: githubUser.login,
email: undefined,
};
const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'github');
const sessionToken = this.sessionsService.generateSessionToken();
const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false);
const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
setSessionCookie(c, sessionCookie);
return c.json({ message: 'ok' });
} catch (error) {
console.error(error);
// the specific error message depends on the provider
if (error instanceof OAuth2RequestError) {
// invalid code
return c.body(null, 400);
}
return c.body(null, 500);
}
})
.get('/google', async (c) => {
try {
const code = c.req.query('code')?.toString() ?? null;
const state = c.req.query('state')?.toString() ?? null;
const storedState = getCookie(c).google_oauth_state ?? null;
const storedCodeVerifier = getCookie(c).google_oauth_code_verifier ?? null;
if (!code || !storedState || !storedCodeVerifier || state !== storedState) {
return c.body(null, 400);
}
const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier);
const googleUserResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const googleUser: GoogleUser = await googleUserResponse.json();
const oAuthUser: OAuthUser = {
sub: googleUser.sub,
given_name: googleUser.given_name,
family_name: googleUser.family_name,
picture: googleUser.picture,
username: googleUser.email,
email: googleUser.email,
email_verified: googleUser.email_verified,
};
const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'google');
const sessionToken = this.sessionsService.generateSessionToken();
const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false);
const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
setSessionCookie(c, sessionCookie);
return c.json({ message: 'ok' });
} catch (error) {
console.error(error);
// the specific error message depends on the provider
if (error instanceof OAuth2RequestError) {
// invalid code
return c.body(null, 400);
}
return c.body(null, 500);
}
});
}
}
interface GitHubUser {
id: number;
login: string;
}
interface GoogleUser {
sub: string;
name: string;
given_name: string;
family_name: string;
picture: string;
email: string;
email_verified: boolean;
}

View file

@ -0,0 +1,27 @@
import type { OAuthProviders, OAuthUser } from '$lib/server/api/common/types/oauth';
import { inject, injectable } from '@needle-di/core';
import { FederatedIdentityRepository } from '../users/federated_identity.repository';
import { UsersService } from '../users/users.service';
@injectable()
export class OAuthService {
constructor(
private federatedIdentityRepository = inject(FederatedIdentityRepository),
private usersService = inject(UsersService),
) {}
async handleOAuthUser(oAuthUser: OAuthUser, oauthProvider: OAuthProviders) {
const federatedUser = await this.federatedIdentityRepository.findOneByFederatedUserIdAndProvider(oAuthUser.sub, oauthProvider);
if (federatedUser) {
return federatedUser.user_id;
}
const user = await this.usersService.createOAuthUser(oAuthUser, oauthProvider);
if (!user) {
throw new Error('Failed to create user');
}
return user.id;
}
}

View file

@ -1,83 +0,0 @@
import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository';
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {collections} from '../databases/postgres/tables';
export type CreateCollection = InferInsertModel<typeof collections>;
export type UpdateCollection = Partial<CreateCollection>;
@injectable()
export class CollectionsRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findAll(db = this.drizzle.db) {
return db.query.collections.findMany();
}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.collections.findFirst({
where: eq(collections.id, id),
columns: {
cuid: true,
name: true,
},
});
}
async findOneByCuid(cuid: string, db = this.drizzle.db) {
return db.query.collections.findFirst({
where: eq(collections.cuid, cuid),
columns: {
cuid: true,
name: true,
},
});
}
async findOneByUserId(userId: string, db = this.drizzle.db) {
return db.query.collections.findFirst({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
},
});
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.collections.findMany({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
createdAt: true,
},
});
}
async findAllByUserIdWithDetails(userId: string, db = this.drizzle.db) {
return db.query.collections.findMany({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
},
with: {
collection_items: {
columns: {
cuid: true,
},
},
},
});
}
async create(data: CreateCollection, db = this.drizzle.db) {
return db.insert(collections).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateCollection, db = this.drizzle.db) {
return db.update(collections).set(data).where(eq(collections.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,70 +0,0 @@
import {credentialsTable, CredentialsType} from '$lib/server/api/databases/postgres/tables/credentials.table';
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {and, eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {takeFirstOrThrow} from '../common/utils/repository';
export type CreateCredentials = InferInsertModel<typeof credentialsTable>;
export type UpdateCredentials = Partial<CreateCredentials>;
export type DeleteCredentials = Pick<CreateCredentials, 'id'>;
@injectable()
export class CredentialsRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneByUserId(userId: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: eq(credentialsTable.user_id, userId),
});
}
async findOneByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type)),
});
}
async findPasswordCredentialsByUserId(userId: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.PASSWORD)),
});
}
async findTOTPCredentialsByUserId(userId: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.TOTP)),
});
}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: eq(credentialsTable.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const credentials = await this.findOneById(id, db);
if (!credentials) throw Error('Credentials not found');
return credentials;
}
async create(data: CreateCredentials, db = this.drizzle.db) {
return db.insert(credentialsTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateCredentials, db = this.drizzle.db) {
return db.update(credentialsTable).set(data).where(eq(credentialsTable.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(credentialsTable).where(eq(credentialsTable.id, id));
}
async deleteByUserId(userId: string, db = this.drizzle.db) {
return db.delete(credentialsTable).where(eq(credentialsTable.user_id, userId));
}
async deleteByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) {
return db.delete(credentialsTable).where(and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type)));
}
}

View file

@ -1,28 +0,0 @@
import {and, eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {takeFirstOrThrow} from '../common/utils/repository';
import {federatedIdentityTable} from '../databases/postgres/tables';
import {DrizzleService} from '../services/drizzle.service';
export type CreateFederatedIdentity = InferInsertModel<typeof federatedIdentityTable>;
@injectable()
export class FederatedIdentityRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneByUserIdAndProvider(userId: string, provider: string) {
return this.drizzle.db.query.federatedIdentityTable.findFirst({
where: and(eq(federatedIdentityTable.user_id, userId), eq(federatedIdentityTable.identity_provider, provider)),
});
}
async findOneByFederatedUserIdAndProvider(federatedUserId: string, provider: string) {
return this.drizzle.db.query.federatedIdentityTable.findFirst({
where: and(eq(federatedIdentityTable.federated_user_id, federatedUserId), eq(federatedIdentityTable.identity_provider, provider)),
});
}
async create(data: CreateFederatedIdentity, db = this.drizzle.db) {
return db.insert(federatedIdentityTable).values(data).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,32 +0,0 @@
import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository';
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {and, eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {recoveryCodesTable} from '../databases/postgres/tables';
export type CreateRecoveryCodes = InferInsertModel<typeof recoveryCodesTable>;
@injectable()
export class RecoveryCodesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async create(data: CreateRecoveryCodes, db = this.drizzle.db) {
return db.insert(recoveryCodesTable).values(data).returning().then(takeFirstOrThrow);
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.recoveryCodesTable.findMany({
where: eq(recoveryCodesTable.userId, userId),
});
}
async findAllNotUsedByUserId(userId: string, db = this.drizzle.db) {
return db.query.recoveryCodesTable.findMany({
where: and(eq(recoveryCodesTable.userId, userId), eq(recoveryCodesTable.used, false)),
});
}
async deleteAllByUserId(userId: string, db = this.drizzle.db) {
return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId));
}
}

View file

@ -1,53 +0,0 @@
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {takeFirstOrThrow} from '../common/utils/repository';
import {rolesTable} from '../databases/postgres/tables';
export type CreateRole = InferInsertModel<typeof rolesTable>;
export type UpdateRole = Partial<CreateRole>;
@injectable()
export class RolesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.rolesTable.findFirst({
where: eq(rolesTable.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const role = await this.findOneById(id, db);
if (!role) throw Error('Role not found');
return role;
}
async findAll(db = this.drizzle.db) {
return db.query.rolesTable.findMany();
}
async findOneByName(name: string, db = this.drizzle.db) {
return db.query.rolesTable.findFirst({
where: eq(rolesTable.name, name),
});
}
async findOneByNameOrThrow(name: string, db = this.drizzle.db) {
const role = await this.findOneByName(name, db);
if (!role) throw Error('Role not found');
return role;
}
async create(data: CreateRole, db = this.drizzle.db) {
return db.insert(rolesTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateRole, db = this.drizzle.db) {
return db.update(rolesTable).set(data).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(rolesTable).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,32 +0,0 @@
import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository';
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {sessionsTable, usersTable} from '../databases/postgres/tables';
export type CreateSession = InferInsertModel<typeof sessionsTable>;
@injectable()
export class SessionsRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async create(data: CreateSession, db = this.drizzle.db) {
return db.insert(sessionsTable).values(data).returning().then(takeFirstOrThrow);
}
async findBySessionId(sessionId: string, db = this.drizzle.db) {
return await db
.select({ user: usersTable, session: sessionsTable })
.from(sessionsTable)
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
.where(eq(sessionsTable.id, sessionId));
}
async deleteBySessionId(sessionId: string, db = this.drizzle.db) {
return db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
}
async updateSessionExpiresAt(sessionId: string, expiresAt: Date, db = this.drizzle.db) {
db.update(sessionsTable).set({ expiresAt }).where(eq(sessionsTable.id, sessionId));
}
}

View file

@ -1,39 +0,0 @@
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {takeFirstOrThrow} from '../common/utils/repository';
import {user_roles} from '../databases/postgres/tables';
export type CreateUserRole = InferInsertModel<typeof user_roles>;
export type UpdateUserRole = Partial<CreateUserRole>;
@injectable()
export class UserRolesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.user_roles.findFirst({
where: eq(user_roles.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const userRole = await this.findOneById(id, db);
if (!userRole) throw Error('User not found');
return userRole;
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.user_roles.findMany({
where: eq(user_roles.user_id, userId),
});
}
async create(data: CreateUserRole, db = this.drizzle.db) {
return db.insert(user_roles).values(data).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(user_roles).where(eq(user_roles.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,49 +0,0 @@
import {usersTable} from '$lib/server/api/databases/postgres/tables/users.table';
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {takeFirstOrThrow} from '../common/utils/repository';
export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser>;
@injectable()
export class UsersRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const user = await this.findOneById(id);
if (!user) throw Error('User not found');
return user;
}
async findOneByUsername(username: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.username, username),
});
}
async findOneByEmail(email: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.email, email),
});
}
async create(data: CreateUser, db = this.drizzle.db) {
return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateUser, db = this.drizzle.db) {
return db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(usersTable).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,66 +0,0 @@
import {DrizzleService} from '$lib/server/api/services/drizzle.service';
import {eq, type InferInsertModel} from 'drizzle-orm';
import {inject, injectable} from '@needle-di/core';
import {takeFirstOrThrow} from '../common/utils/repository';
import {wishlistsTable} from '../databases/postgres/tables';
export type CreateWishlist = InferInsertModel<typeof wishlistsTable>;
export type UpdateWishlist = Partial<CreateWishlist>;
@injectable()
export class WishlistsRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findAll(db = this.drizzle.db) {
return db.query.wishlistsTable.findMany();
}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.wishlistsTable.findFirst({
where: eq(wishlistsTable.id, id),
columns: {
cuid: true,
name: true,
},
});
}
async findOneByCuid(cuid: string, db = this.drizzle.db) {
return db.query.wishlistsTable.findFirst({
where: eq(wishlistsTable.cuid, cuid),
columns: {
cuid: true,
name: true,
},
});
}
async findOneByUserId(userId: string, db = this.drizzle.db) {
return db.query.wishlistsTable.findFirst({
where: eq(wishlistsTable.user_id, userId),
columns: {
cuid: true,
name: true,
},
});
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.wishlistsTable.findMany({
where: eq(wishlistsTable.user_id, userId),
columns: {
cuid: true,
name: true,
createdAt: true,
},
});
}
async create(data: CreateWishlist, db = this.drizzle.db) {
return db.insert(wishlistsTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateWishlist, db = this.drizzle.db) {
return db.update(wishlistsTable).set(data).where(eq(wishlistsTable.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,50 +0,0 @@
import type {db} from '$lib/server/api/packages/drizzle'
import {generateRandomAnimalName} from '$lib/utils/randomDataUtil'
import {inject, injectable} from '@needle-di/core'
import {CollectionsRepository} from '../repositories/collections.repository'
@injectable()
export class CollectionsService {
constructor(private collectionsRepository = inject(CollectionsRepository)) {}
async findOneByUserId(userId: string) {
return this.collectionsRepository.findOneByUserId(userId)
}
async findAllByUserId(userId: string) {
return this.collectionsRepository.findAllByUserId(userId)
}
async findAllByUserIdWithDetails(userId: string) {
return this.collectionsRepository.findAllByUserIdWithDetails(userId)
}
async findOneById(id: string) {
return this.collectionsRepository.findOneById(id)
}
async findOneByCuid(cuid: string) {
return this.collectionsRepository.findOneByCuid(cuid)
}
async createEmptyNoName(userId: string, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.createEmpty(userId, null, trx)
}
async createEmpty(userId: string, name: string | null, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
if (!trx) {
return this.collectionsRepository.create({
user_id: userId,
name: name ?? generateRandomAnimalName(),
})
}
return this.collectionsRepository.create(
{
user_id: userId,
name: name ?? generateRandomAnimalName(),
},
trx,
)
}
}

View file

@ -1,35 +0,0 @@
import {drizzle, type NodePgDatabase} from 'drizzle-orm/node-postgres';
import pg from 'pg';
import {type Disposable, injectable} from '@needle-di/core';
import {config} from '../common/config';
import * as schema from '../databases/postgres/tables';
@injectable()
export class DrizzleService implements Disposable {
protected readonly pool: pg.Pool;
db: NodePgDatabase<typeof schema>;
readonly schema: typeof schema = schema;
constructor() {
const pool = new pg.Pool({
user: config.postgres.user,
password: config.postgres.password,
host: config.postgres.host,
port: Number(config.postgres.port).valueOf(),
database: config.postgres.database,
ssl: config.postgres.ssl,
max: config.postgres.max,
});
this.pool = pool;
this.db = drizzle({
client: pool,
casing: 'snake_case',
schema,
logger: !config.isProduction,
});
}
dispose(): Promise<void> | void {
this.pool.end();
}
}

View file

@ -1,51 +0,0 @@
import {scrypt} from 'node:crypto'
import {decodeHex, encodeHexLowerCase} from '@oslojs/encoding'
import {constantTimeEqual} from '@oslojs/crypto/subtle'
import {injectable} from '@needle-di/core'
@injectable()
export class HashingService {
private N: number
private r: number
private p: number
private dkLen: number
constructor() {
this.N = 16384
this.r = 16
this.p = 1
this.dkLen = 64
}
async hash(password: string) {
const salt = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(16)))
const key = await this.generateKey(password, salt)
return `${salt}:${encodeHexLowerCase(key)}`
}
async verify(hash: string, password: string) {
const [salt, key] = hash.split(':')
const targetKey = await this.generateKey(password, salt)
return constantTimeEqual(targetKey, decodeHex(key))
}
async generateKey(password: string, salt: string): Promise<Buffer> {
return await new Promise((resolve, reject) => {
scrypt(
password.normalize('NFKC'),
salt,
this.dkLen,
{
N: this.N,
p: this.p,
r: this.r,
// errors when 128 * N * r > `maxmem` (approximately)
maxmem: 128 * this.N * this.r * 2,
},
(err, buff) => {
if (err) return reject(err)
return resolve(buff)
},
)
})
}
}

View file

@ -1,27 +0,0 @@
import {inject, injectable} from '@needle-di/core'
import {FederatedIdentityRepository} from '../repositories/federated_identity.repository'
import {UsersService} from './users.service'
import type {OAuthProviders, OAuthUser} from "$lib/server/api/common/types/oauth";
@injectable()
export class OAuthService {
constructor(
private federatedIdentityRepository = inject(FederatedIdentityRepository),
private usersService = inject(UsersService),
) {}
async handleOAuthUser(oAuthUser: OAuthUser, oauthProvider: OAuthProviders) {
const federatedUser = await this.federatedIdentityRepository.findOneByFederatedUserIdAndProvider(oAuthUser.sub, oauthProvider)
if (federatedUser) {
return federatedUser.user_id
}
const user = await this.usersService.createOAuthUser(oAuthUser, oauthProvider)
if (!user) {
throw new Error('Failed to create user')
}
return user.id
}
}

View file

@ -1,40 +0,0 @@
import {RecoveryCodesRepository} from '$lib/server/api/repositories/recovery-codes.repository'
import {alphabet, generateRandomString} from 'oslo/crypto'
import {inject, injectable} from '@needle-di/core'
import {HashingService} from './hashing.service'
@injectable()
export class RecoveryCodesService {
constructor(
private hashingService = inject(HashingService),
private recoveryCodesRepository = inject(RecoveryCodesRepository),
) {}
async findAllRecoveryCodesByUserId(userId: string) {
return this.recoveryCodesRepository.findAllByUserId(userId)
}
async createRecoveryCodes(userId: string) {
const createdRecoveryCodes = Array.from({ length: 5 }, () => generateRandomString(10, alphabet('A-Z', '0-9')))
if (createdRecoveryCodes && userId) {
for (const code of createdRecoveryCodes) {
const hashedCode = await this.hashingService.hash(code)
console.log('Inserting recovery code', code, hashedCode)
await this.recoveryCodesRepository.create({ userId, code: hashedCode })
}
return createdRecoveryCodes
}
return []
}
async verify(userId: string, code: string) {
const recoveryCodes = await this.recoveryCodesRepository.findAllNotUsedByUserId(userId);
return recoveryCodes.find(recoveryCode => this.hashingService.verify(recoveryCode.code, code))
}
async deleteAllRecoveryCodesByUserId(userId: string) {
return this.recoveryCodesRepository.deleteAllByUserId(userId)
}
}

View file

@ -1,40 +0,0 @@
import {config} from '$lib/server/api/common/config'
import {Redis} from 'ioredis'
import { injectable} from '@needle-di/core';
import type {Disposable} from 'tsyringe';
@injectable()
export class RedisService implements Disposable {
readonly client: Redis;
constructor() {
this.client = new Redis(config.redis.url, {
maxRetriesPerRequest: null,
});
}
async get(data: { prefix: string; key: string }): Promise<string | null> {
return this.client.get(`${data.prefix}:${data.key}`);
}
async set(data: { prefix: string; key: string; value: string }): Promise<void> {
await this.client.set(`${data.prefix}:${data.key}`, data.value);
}
async delete(data: { prefix: string; key: string }): Promise<void> {
await this.client.del(`${data.prefix}:${data.key}`);
}
async setWithExpiry(data: {
prefix: string;
key: string;
value: string;
expiry: number;
}): Promise<void> {
await this.client.set(`${data.prefix}:${data.key}`, data.value, 'EXAT', Math.floor(data.expiry));
}
async dispose(): Promise<void> {
this.client.disconnect();
}
}

View file

@ -1,14 +0,0 @@
import {inject, injectable} from "@needle-di/core";
import {RolesRepository} from "$lib/server/api/repositories/roles.repository";
@injectable()
export class RolesService {
constructor(
private rolesRepository = inject(RolesRepository)
) { }
async findOneByNameOrThrow(name: string) {
return this.rolesRepository.findOneByNameOrThrow(name);
}
}

View file

@ -1,135 +0,0 @@
import { cookieExpiresAt, cookieExpiresMilliseconds, halfCookieExpiresMilliseconds } from '$lib/server/api/common/utils/cookies';
import { UsersRepository } from '$lib/server/api/repositories/users.repository';
import { RedisService } from '$lib/server/api/services/redis.service';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { inject, injectable } from '@needle-di/core';
import type { Users } from '../databases/postgres/tables';
export type RedisSession = {
id: string;
user_id: string;
expires_at: number;
ip_country: string;
ip_address: string;
two_factor_auth_enabled: boolean;
is_two_factor_authenticated: boolean;
};
export type Session = {
id: string;
userId: string;
expiresAt: Date;
ipCountry: string;
ipAddress: string;
twoFactorEnabled: boolean;
twoFactorVerified: boolean;
};
export type SessionValidationResult = { session: Session; user: Users } | { session: null; user: null } | { session: Session; user: undefined };
@injectable()
export class SessionsService {
constructor(
private redisService = inject(RedisService),
private usersRepository = inject(UsersRepository),
) {}
generateSessionToken() {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
async createSession(
token: string,
userId: string,
ipCountry: string,
ipAddress: string,
twoFactorAuthEnabled: boolean,
twoFactorVerified: boolean,
) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session = {
id: sessionId,
userId,
expiresAt: cookieExpiresAt,
ipCountry,
ipAddress,
twoFactorAuthEnabled,
twoFactorVerified,
};
await this.redisService.client.set(
`session:${sessionId}`,
JSON.stringify({
id: session.id,
user_id: session.userId,
expires_at: session.expiresAt,
ip_country: session.ipCountry,
ip_address: session.ipAddress,
two_factor_auth_enabled: session.twoFactorAuthEnabled,
is_two_factor_authenticated: session.twoFactorVerified,
}),
'EXAT',
Math.floor(session.expiresAt.getTime() / 1000),
);
return session;
}
async validateSessionToken(token: string): Promise<SessionValidationResult> {
// TODO: Why was this needed in the docs? https://lucia-next.pages.dev/sessions/basic-api/drizzle-orm
// const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const item = await this.redisService.client.get(`session:${token}`);
if (item === null) {
return {
session: null,
user: null,
};
}
const result: RedisSession = JSON.parse(item);
const session: Session = {
id: result.id,
userId: result.user_id,
expiresAt: new Date(result.expires_at * 1000),
ipCountry: result.ip_country,
ipAddress: result.ip_address,
twoFactorEnabled: result.two_factor_auth_enabled,
twoFactorVerified: result.is_two_factor_authenticated,
};
let user: Users | undefined = undefined;
if (session.userId) {
user = await this.usersRepository.findOneById(session.userId);
}
if (Date.now() >= session.expiresAt.getTime()) {
await this.redisService.client.del(`session:${token}`);
return {
session: null,
user: null,
};
}
if (Date.now() >= session.expiresAt.getTime() - cookieExpiresMilliseconds) {
session.expiresAt = new Date(Date.now() + halfCookieExpiresMilliseconds);
await this.redisService.client.set(
`session:${token}`,
JSON.stringify({
id: session.id,
user_id: session.userId,
expires_at: Math.floor(session.expiresAt.getTime() / 1000),
ip_country: session.ipCountry,
ip_address: session.ipAddress,
two_factor_enabled: session.twoFactorEnabled,
two_factor_verified: session.twoFactorVerified,
}),
'EXAT',
Math.floor(session.expiresAt.getTime() / 1000),
);
}
return { session, user };
}
async invalidateSession(sessionId: string) {
await this.redisService.client.del(`session:${sessionId}`);
}
}

View file

@ -1,39 +1,39 @@
import {inject, injectable} from "@needle-di/core";
import {generateRandomString} from "oslo/crypto";
import {createDate, TimeSpan, type TimeSpanUnit} from 'oslo';
import {HashingService} from "./hashing.service";
import { inject, injectable } from '@needle-di/core';
import { TimeSpan, type TimeSpanUnit, createDate } from 'oslo';
import { generateRandomString } from 'oslo/crypto';
import { HashingService } from '../common/services/hashing.service';
@injectable()
export class TokensService {
constructor(private hashingService = inject(HashingService)) { }
constructor(private hashingService = inject(HashingService)) {}
generateToken() {
const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; // alphabet with removed look-alike characters (0, 1, O, I)
return generateRandomString(6, alphabet);
}
generateToken() {
const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; // alphabet with removed look-alike characters (0, 1, O, I)
return generateRandomString(6, alphabet);
}
generateTokenWithExpiry(number: number, lifespan: TimeSpanUnit) {
return {
token: this.generateToken(),
expiry: createDate(new TimeSpan(number, lifespan))
}
}
generateTokenWithExpiry(number: number, lifespan: TimeSpanUnit) {
return {
token: this.generateToken(),
expiry: createDate(new TimeSpan(number, lifespan)),
};
}
async generateTokenWithExpiryAndHash(number: number, lifespan: TimeSpanUnit) {
const token = this.generateToken()
const hashedToken = await this.hashingService.hash(token)
return {
token,
hashedToken,
expiry: createDate(new TimeSpan(number, lifespan))
}
}
async generateTokenWithExpiryAndHash(number: number, lifespan: TimeSpanUnit) {
const token = this.generateToken();
const hashedToken = await this.hashingService.hash(token);
return {
token,
hashedToken,
expiry: createDate(new TimeSpan(number, lifespan)),
};
}
async createHashedToken(token: string) {
return this.hashingService.hash(token)
}
async createHashedToken(token: string) {
return this.hashingService.hash(token);
}
async verifyHashedToken(hashedToken: string, token: string) {
return this.hashingService.verify(hashedToken, token)
}
async verifyHashedToken(hashedToken: string, token: string) {
return this.hashingService.verify(hashedToken, token);
}
}

View file

@ -1,51 +0,0 @@
import type {db} from '$lib/server/api/packages/drizzle'
import {type CreateUserRole, UserRolesRepository} from '$lib/server/api/repositories/user_roles.repository'
import {RolesService} from '$lib/server/api/services/roles.service'
import {inject, injectable} from '@needle-di/core'
@injectable()
export class UserRolesService {
constructor(
private userRolesRepository = inject(UserRolesRepository),
private rolesService = inject(RolesService),
) {}
async findOneById(id: string) {
return this.userRolesRepository.findOneById(id)
}
async findAllByUserId(userId: string) {
return this.userRolesRepository.findAllByUserId(userId)
}
async create(data: CreateUserRole) {
return this.userRolesRepository.create(data)
}
async addRoleToUser(userId: string, roleName: string, primary = false, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
// Find the role by its name
const role = await this.rolesService.findOneByNameOrThrow(roleName)
if (!role || !role.id) {
throw new Error(`Role with name ${roleName} not found`)
}
if (!trx) {
return this.userRolesRepository.create({
user_id: userId,
role_id: role.id,
primary,
})
}
// Create a UserRole entry linking the user and the role
return this.userRolesRepository.create(
{
user_id: userId,
role_id: role.id,
primary,
},
trx,
)
}
}

View file

@ -1,150 +0,0 @@
import type {OAuthUser} from '$lib/server/api/common/types/oauth';
import type {SignupUsernameEmailDto} from '$lib/server/api/dtos/signup-username-email.dto';
import {CredentialsRepository} from '$lib/server/api/repositories/credentials.repository';
import {FederatedIdentityRepository} from '$lib/server/api/repositories/federated_identity.repository';
import {WishlistsRepository} from '$lib/server/api/repositories/wishlists.repository';
import {TokensService} from '$lib/server/api/services/tokens.service';
import {UserRolesService} from '$lib/server/api/services/user_roles.service';
import { inject, injectable } from '@needle-di/core';
import {CredentialsType, RoleName} from '../databases/postgres/tables';
import {type UpdateUser, UsersRepository} from '../repositories/users.repository';
import {CollectionsService} from './collections.service';
import {DrizzleService} from './drizzle.service';
import {WishlistsService} from './wishlists.service';
@injectable()
export class UsersService {
constructor(
private collectionsService = inject(CollectionsService),
private credentialsRepository = inject(CredentialsRepository),
private drizzleService = inject(DrizzleService),
private federatedIdentityRepository = inject(FederatedIdentityRepository),
private tokenService = inject(TokensService),
private usersRepository = inject(UsersRepository),
private userRolesService = inject(UserRolesService),
private wishlistsRepository = inject(WishlistsRepository),
private wishlistsService = inject(WishlistsService),
) {}
async create(data: SignupUsernameEmailDto) {
const { firstName, lastName, email, username, password } = data;
const hashedPassword = await this.tokenService.createHashedToken(password);
return await this.drizzleService.db.transaction(async (trx) => {
const createdUser = await this.usersRepository.create(
{
first_name: firstName,
last_name: lastName,
email,
username,
},
trx,
);
if (!createdUser) {
return null;
}
const credentials = await this.credentialsRepository.create(
{
user_id: createdUser.id,
type: CredentialsType.PASSWORD,
secret_data: hashedPassword,
},
trx,
);
if (!credentials) {
await this.usersRepository.delete(createdUser.id);
return null;
}
await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx);
await this.wishlistsService.createEmptyNoName(createdUser.id, trx);
await this.collectionsService.createEmptyNoName(createdUser.id, trx);
return createdUser;
});
}
async createOAuthUser(oAuthUser: OAuthUser, oauthProvider: string) {
return await this.drizzleService.db.transaction(async (trx) => {
const createdUser = await this.usersRepository.create(
{
username: oAuthUser.username || oAuthUser.username,
email: oAuthUser.email || null,
first_name: oAuthUser.given_name || null,
last_name: oAuthUser.family_name || null,
picture: oAuthUser.picture || null,
email_verified: oAuthUser.email_verified || false,
},
trx,
);
if (!createdUser) {
return null;
}
await this.federatedIdentityRepository.create(
{
identity_provider: oauthProvider,
user_id: createdUser.id,
federated_user_id: oAuthUser.sub,
federated_username: oAuthUser.email || oAuthUser.username,
},
trx,
);
await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx);
await this.wishlistsService.createEmptyNoName(createdUser.id, trx);
await this.collectionsService.createEmptyNoName(createdUser.id, trx);
return createdUser;
});
}
async updateUser(userId: string, data: UpdateUser) {
return this.usersRepository.update(userId, data);
}
async findOneByUsername(username: string) {
return this.usersRepository.findOneByUsername(username);
}
async findOneByEmail(email: string) {
return this.usersRepository.findOneByEmail(email);
}
async findOneById(id: string) {
return this.usersRepository.findOneById(id);
}
async updatePassword(userId: string, password: string) {
const hashedPassword = await this.tokenService.createHashedToken(password);
const currentCredentials = await this.credentialsRepository.findPasswordCredentialsByUserId(userId);
if (!currentCredentials) {
await this.credentialsRepository.create({
user_id: userId,
type: CredentialsType.PASSWORD,
secret_data: hashedPassword,
});
} else {
await this.credentialsRepository.update(currentCredentials.id, {
secret_data: hashedPassword,
});
}
}
async verifyPassword(userId: string, data: { password: string }) {
const user = await this.usersRepository.findOneById(userId);
if (!user) {
throw new Error('User not found');
}
const credential = await this.credentialsRepository.findOneByUserIdAndType(userId, CredentialsType.PASSWORD);
if (!credential) {
throw new Error('Password credentials not found');
}
const { password } = data;
return this.tokenService.verifyHashedToken(credential.secret_data, password);
}
}

View file

@ -1,41 +0,0 @@
import type {db} from '$lib/server/api/packages/drizzle'
import {generateRandomAnimalName} from '$lib/utils/randomDataUtil'
import {inject, injectable} from '@needle-di/core'
import {WishlistsRepository} from '../repositories/wishlists.repository'
@injectable()
export class WishlistsService {
constructor(private wishlistsRepository = inject(WishlistsRepository)) {}
async findAllByUserId(userId: string) {
return this.wishlistsRepository.findAllByUserId(userId)
}
async findOneById(id: string) {
return this.wishlistsRepository.findOneById(id)
}
async findOneByCuid(cuid: string) {
return this.wishlistsRepository.findOneByCuid(cuid)
}
async createEmptyNoName(userId: string, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.createEmpty(userId, null, trx)
}
async createEmpty(userId: string, name: string | null, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
if (!trx) {
return this.wishlistsRepository.create({
user_id: userId,
name: name ?? generateRandomAnimalName(),
})
}
return this.wishlistsRepository.create(
{
user_id: userId,
name: name ?? generateRandomAnimalName(),
},
trx,
)
}
}

View file

@ -0,0 +1,44 @@
import 'reflect-metadata';
import { limiter } from '$lib/server/api/common/middleware/rate-limit.middleware';
import { Controller } from '$lib/server/api/common/types/controller';
import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies';
import { signupUsernameEmailDto } from '$lib/server/api/dtos/signup-username-email.dto';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { LoginRequestsService } from '$lib/server/api/login/loginrequest.service';
import { UsersService } from '$lib/server/api/users/users.service';
import { zValidator } from '@hono/zod-validator';
import { inject, injectable } from '@needle-di/core';
@injectable()
export class SignupController extends Controller {
constructor(
private usersService = inject(UsersService),
private loginRequestService = inject(LoginRequestsService),
private sessionsService = inject(SessionsService),
) {
super();
}
routes() {
return this.controller.post('/', zValidator('json', signupUsernameEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { firstName, lastName, email, username, password, confirm_password } = await c.req.valid('json');
const existingUser = await this.usersService.findOneByUsername(username);
if (existingUser) {
return c.body('User already exists', 400);
}
const user = await this.usersService.create({ firstName, lastName, email, username, password, confirm_password });
if (!user) {
return c.body('Failed to create user', 500);
}
const session = await this.loginRequestService.createUserSession(user.id, c.req, false, false);
const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
console.log('set cookie', sessionCookie);
setSessionCookie(c, sessionCookie);
return c.json({ message: 'ok' });
});
}
}

View file

@ -1,38 +0,0 @@
import { Container } from '@needle-di/core';
import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'
import {HashingService} from '../services/hashing.service'
describe('HashingService', () => {
let service: HashingService
const container = new Container()
beforeAll(() => {
service = container.get(HashingService)
})
afterAll(() => {
vi.resetAllMocks()
})
describe('Create Hash', () => {
it('should create a hash', async () => {
const hash = await service.hash('111')
expect(hash).not.toBeUndefined()
expect(hash).not.toBeNull()
})
})
describe('Verify Hash', () => {
it('should verify a hash', async () => {
const hash = await service.hash('111')
const verifiable = await service.verify(hash, '111')
expect(verifiable).toBeTruthy()
})
it('should not verify a hash', async () => {
const hash = await service.hash('111')
const verifiable = await service.verify(hash, '222')
expect(verifiable).toBeFalsy()
})
})
})

View file

@ -1,125 +1,125 @@
import {IamService} from '$lib/server/api/services/iam.service';
import {SessionsService} from '$lib/server/api/services/sessions.service';
import {UsersService} from '$lib/server/api/services/users.service';
import {faker} from '@faker-js/faker';
import { IamService } from '$lib/server/api/iam/iam.service';
import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service';
import { UsersService } from '$lib/server/api/users/users.service';
import { faker } from '@faker-js/faker';
import { Container } from '@needle-di/core';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
describe('IamService', () => {
let service: IamService;
const container = new Container();
const sessionService = vi.mocked(SessionsService.prototype);
const userService = vi.mocked(UsersService.prototype);
let service: IamService;
const container = new Container();
const sessionService = vi.mocked(SessionsService.prototype);
const userService = vi.mocked(UsersService.prototype);
beforeAll(() => {
container
.bind<SessionsService>({ provide: SessionsService, useValue: sessionService })
.bind<UsersService>({ provide: UsersService, useValue: userService });
beforeAll(() => {
container
.bind<SessionsService>({ provide: SessionsService, useValue: sessionService })
.bind<UsersService>({ provide: UsersService, useValue: userService });
service = container.get(IamService);
});
service = container.get(IamService);
});
beforeEach(() => {
vi.resetAllMocks();
});
beforeEach(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
const timeStampDate = new Date();
const dbUser = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
verified: false,
receive_email: false,
mfa_enabled: false,
theme: 'system',
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const timeStampDate = new Date();
const dbUser = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
verified: false,
receive_email: false,
mfa_enabled: false,
theme: 'system',
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
describe('Update Profile', () => {
it('should update user', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser);
userService.findOneByUsername = vi.fn().mockResolvedValue(undefined);
userService.updateUser = vi.fn().mockResolvedValue(dbUser);
describe('Update Profile', () => {
it('should update user', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser);
userService.findOneByUsername = vi.fn().mockResolvedValue(undefined);
userService.updateUser = vi.fn().mockResolvedValue(dbUser);
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual(dbUser);
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(1);
expect(spy_userService_updateUser).toBeCalledTimes(1);
});
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual(dbUser);
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(1);
expect(spy_userService_updateUser).toBeCalledTimes(1);
});
it('should error on no user found', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(undefined);
it('should error on no user found', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(undefined);
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual({
error: 'User not found',
});
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(0);
expect(spy_userService_updateUser).toBeCalledTimes(0);
});
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual({
error: 'User not found',
});
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(0);
expect(spy_userService_updateUser).toBeCalledTimes(0);
});
it('should error on duplicate username', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser);
userService.findOneByUsername = vi.fn().mockResolvedValue({
id: faker.string.uuid(),
});
userService.updateUser = vi.fn().mockResolvedValue(dbUser);
it('should error on duplicate username', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser);
userService.findOneByUsername = vi.fn().mockResolvedValue({
id: faker.string.uuid(),
});
userService.updateUser = vi.fn().mockResolvedValue(dbUser);
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual({
error: 'Username already in use',
});
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(1);
expect(spy_userService_updateUser).toBeCalledTimes(0);
});
});
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual({
error: 'Username already in use',
});
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(1);
expect(spy_userService_updateUser).toBeCalledTimes(0);
});
});
it('should not error if the user id of new username is the current user id', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser);
userService.findOneByUsername = vi.fn().mockResolvedValue({
id: dbUser.id,
});
userService.updateUser = vi.fn().mockResolvedValue(dbUser);
it('should not error if the user id of new username is the current user id', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser);
userService.findOneByUsername = vi.fn().mockResolvedValue({
id: dbUser.id,
});
userService.updateUser = vi.fn().mockResolvedValue(dbUser);
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(dbUser.id, {
username: dbUser.id,
}),
).resolves.toEqual(dbUser);
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(1);
expect(spy_userService_updateUser).toBeCalledTimes(1);
});
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById');
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername');
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser');
await expect(
service.updateProfile(dbUser.id, {
username: dbUser.id,
}),
).resolves.toEqual(dbUser);
expect(spy_userService_findOneById).toBeCalledTimes(1);
expect(spy_userService_findOneByUsername).toBeCalledTimes(1);
expect(spy_userService_updateUser).toBeCalledTimes(1);
});
});

View file

@ -1,46 +1,46 @@
import 'reflect-metadata'
import { Container } from '@needle-di/core'
import {afterAll, beforeAll, describe, expect, expectTypeOf, it, vi} from 'vitest'
import {HashingService} from '../services/hashing.service'
import {TokensService} from '../services/tokens.service'
import 'reflect-metadata';
import { Container } from '@needle-di/core';
import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest';
import { HashingService } from '../common/services/hashing.service';
import { TokensService } from '../services/tokens.service';
describe('TokensService', () => {
const container = new Container()
let service: TokensService
const hashingService = vi.mocked(HashingService.prototype)
const container = new Container();
let service: TokensService;
const hashingService = vi.mocked(HashingService.prototype);
beforeAll(() => {
container.bind<HashingService>({ provide: HashingService, useValue: hashingService });
service = container.get(TokensService);
})
beforeAll(() => {
container.bind<HashingService>({ provide: HashingService, useValue: hashingService });
service = container.get(TokensService);
});
afterAll(() => {
vi.resetAllMocks()
})
afterAll(() => {
vi.resetAllMocks();
});
describe('Generate Token', () => {
it('should resolve', async () => {
const hashedPassword = 'testhash'
hashingService.hash = vi.fn().mockResolvedValue(hashedPassword)
const spy_hashingService_hash = vi.spyOn(hashingService, 'hash')
const spy_hashingService_verify = vi.spyOn(hashingService, 'verify')
await expectTypeOf(service.createHashedToken('111')).resolves.toBeString()
expect(spy_hashingService_hash).toBeCalledTimes(1)
expect(spy_hashingService_verify).toBeCalledTimes(0)
})
it('should generate a token that is verifiable', async () => {
hashingService.hash = vi.fn().mockResolvedValue('testhash')
hashingService.verify = vi.fn().mockResolvedValue(true)
const spy_hashingService_hash = vi.spyOn(hashingService, 'hash')
const spy_hashingService_verify = vi.spyOn(hashingService, 'verify')
const token = await service.createHashedToken('111')
expect(token).not.toBeNaN()
expect(token).not.toBeUndefined()
expect(token).not.toBeNull()
const verifiable = await service.verifyHashedToken(token, '111')
expect(verifiable).toBeTruthy()
expect(spy_hashingService_hash).toBeCalledTimes(1)
expect(spy_hashingService_verify).toBeCalledTimes(1)
})
})
})
describe('Generate Token', () => {
it('should resolve', async () => {
const hashedPassword = 'testhash';
hashingService.hash = vi.fn().mockResolvedValue(hashedPassword);
const spy_hashingService_hash = vi.spyOn(hashingService, 'hash');
const spy_hashingService_verify = vi.spyOn(hashingService, 'verify');
await expectTypeOf(service.createHashedToken('111')).resolves.toBeString();
expect(spy_hashingService_hash).toBeCalledTimes(1);
expect(spy_hashingService_verify).toBeCalledTimes(0);
});
it('should generate a token that is verifiable', async () => {
hashingService.hash = vi.fn().mockResolvedValue('testhash');
hashingService.verify = vi.fn().mockResolvedValue(true);
const spy_hashingService_hash = vi.spyOn(hashingService, 'hash');
const spy_hashingService_verify = vi.spyOn(hashingService, 'verify');
const token = await service.createHashedToken('111');
expect(token).not.toBeNaN();
expect(token).not.toBeUndefined();
expect(token).not.toBeNull();
const verifiable = await service.verifyHashedToken(token, '111');
expect(verifiable).toBeTruthy();
expect(spy_hashingService_hash).toBeCalledTimes(1);
expect(spy_hashingService_verify).toBeCalledTimes(1);
});
});
});

View file

@ -1,77 +1,77 @@
import 'reflect-metadata';
import {faker} from '@faker-js/faker';
import { faker } from '@faker-js/faker';
import { Container } from '@needle-di/core';
import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest';
import {RoleName} from '../databases/postgres/tables';
import {UserRolesRepository} from '../repositories/user_roles.repository';
import {RolesService} from '../services/roles.service';
import {UserRolesService} from '../services/user_roles.service';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { RoleName } from '../databases/postgres/tables';
import { RolesService } from '../users/roles.service';
import { UserRolesRepository } from '../users/user_roles.repository';
import { UserRolesService } from '../users/user_roles.service';
describe('UserRolesService', () => {
const container = new Container();
let service: UserRolesService;
const userRolesRepository = vi.mocked(UserRolesRepository.prototype);
const rolesService = vi.mocked(RolesService.prototype);
const container = new Container();
let service: UserRolesService;
const userRolesRepository = vi.mocked(UserRolesRepository.prototype);
const rolesService = vi.mocked(RolesService.prototype);
beforeAll(() => {
container
.bind<UserRolesRepository>({ provide: UserRolesRepository, useValue: userRolesRepository })
.bind<RolesService>({ provide: RolesService, useValue: rolesService });
beforeAll(() => {
container
.bind<UserRolesRepository>({ provide: UserRolesRepository, useValue: userRolesRepository })
.bind<RolesService>({ provide: RolesService, useValue: rolesService });
service = container.get(UserRolesService);
});
service = container.get(UserRolesService);
});
afterAll(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
const timeStampDate = new Date();
const roleUUID = faker.string.uuid();
const userUUID = faker.string.uuid();
const dbRole = {
id: roleUUID,
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
name: RoleName.ADMIN,
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const timeStampDate = new Date();
const roleUUID = faker.string.uuid();
const userUUID = faker.string.uuid();
const dbRole = {
id: roleUUID,
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
name: RoleName.ADMIN,
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const dbUserRole = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
role_id: roleUUID,
user_id: userUUID,
primary: true,
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const dbUserRole = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
role_id: roleUUID,
user_id: userUUID,
primary: true,
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
describe('Create User Role', () => {
it('should resolve', async () => {
rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(dbRole satisfies Awaited<ReturnType<typeof rolesService.findOneByNameOrThrow>>);
describe('Create User Role', () => {
it('should resolve', async () => {
rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(dbRole satisfies Awaited<ReturnType<typeof rolesService.findOneByNameOrThrow>>);
userRolesRepository.create = vi.fn().mockResolvedValue(dbUserRole satisfies Awaited<ReturnType<typeof userRolesRepository.create>>);
userRolesRepository.create = vi.fn().mockResolvedValue(dbUserRole satisfies Awaited<ReturnType<typeof userRolesRepository.create>>);
const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow');
const spy_userRolesRepository_create = vi.spyOn(userRolesRepository, 'create');
const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow');
const spy_userRolesRepository_create = vi.spyOn(userRolesRepository, 'create');
await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).resolves.not.toThrowError();
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN);
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1);
expect(spy_userRolesRepository_create).toBeCalledWith({
user_id: userUUID,
role_id: dbRole.id,
primary: true,
});
expect(spy_userRolesRepository_create).toBeCalledTimes(1);
});
it('should error on no role found', async () => {
rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(undefined);
await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).resolves.not.toThrowError();
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN);
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1);
expect(spy_userRolesRepository_create).toBeCalledWith({
user_id: userUUID,
role_id: dbRole.id,
primary: true,
});
expect(spy_userRolesRepository_create).toBeCalledTimes(1);
});
it('should error on no role found', async () => {
rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(undefined);
const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow');
await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).rejects.toThrowError(`Role with name ${RoleName.ADMIN} not found`);
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN);
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1);
});
});
const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow');
await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).rejects.toThrowError(`Role with name ${RoleName.ADMIN} not found`);
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN);
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1);
});
});
});

View file

@ -1,143 +1,143 @@
import 'reflect-metadata';
import {faker} from '@faker-js/faker';
import { faker } from '@faker-js/faker';
import { Container } from '@needle-di/core';
import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest';
import {CredentialsType} from '../databases/postgres/tables';
import {CredentialsRepository} from '../repositories/credentials.repository';
import {UsersRepository} from '../repositories/users.repository';
import {CollectionsService} from '../services/collections.service';
import {DrizzleService} from '../services/drizzle.service';
import {TokensService} from '../services/tokens.service';
import {UserRolesService} from '../services/user_roles.service';
import {UsersService} from '../services/users.service';
import {WishlistsService} from '../services/wishlists.service';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { CollectionsService } from '../collections/collections.service';
import { DrizzleService } from '../databases/postgres/drizzle.service';
import { CredentialsType } from '../databases/postgres/tables';
import { TokensService } from '../services/tokens.service';
import { CredentialsRepository } from '../users/credentials.repository';
import { UserRolesService } from '../users/user_roles.service';
import { UsersRepository } from '../users/users.repository';
import { UsersService } from '../users/users.service';
import { WishlistsService } from '../wishlists/wishlists.service';
describe('UsersService', () => {
const container = new Container();
let service: UsersService;
const credentialsRepository = vi.mocked(CredentialsRepository.prototype);
const drizzleService = vi.mocked(DrizzleService.prototype, { deep: true });
const tokensService = vi.mocked(TokensService.prototype);
const usersRepository = vi.mocked(UsersRepository.prototype);
const userRolesService = vi.mocked(UserRolesService.prototype);
const wishlistsService = vi.mocked(WishlistsService.prototype);
const collectionsService = vi.mocked(CollectionsService.prototype);
const container = new Container();
let service: UsersService;
const credentialsRepository = vi.mocked(CredentialsRepository.prototype);
const drizzleService = vi.mocked(DrizzleService.prototype, { deep: true });
const tokensService = vi.mocked(TokensService.prototype);
const usersRepository = vi.mocked(UsersRepository.prototype);
const userRolesService = vi.mocked(UserRolesService.prototype);
const wishlistsService = vi.mocked(WishlistsService.prototype);
const collectionsService = vi.mocked(CollectionsService.prototype);
// Mocking the dependencies
vi.mock('pg', () => ({
Pool: vi.fn().mockImplementation(() => ({
connect: vi.fn(),
end: vi.fn(),
})),
}));
// Mocking the dependencies
vi.mock('pg', () => ({
Pool: vi.fn().mockImplementation(() => ({
connect: vi.fn(),
end: vi.fn(),
})),
}));
vi.mock('drizzle-orm/node-postgres', () => ({
drizzle: vi.fn().mockImplementation(() => ({
transaction: vi.fn().mockImplementation((callback) => callback()),
// Add other methods you need to mock
})),
}));
vi.mock('drizzle-orm/node-postgres', () => ({
drizzle: vi.fn().mockImplementation(() => ({
transaction: vi.fn().mockImplementation((callback) => callback()),
// Add other methods you need to mock
})),
}));
beforeAll(() => {
container
.bind<CredentialsRepository>({ provide: CredentialsRepository, useValue: credentialsRepository })
.bind<DrizzleService>({ provide: DrizzleService, useValue: drizzleService })
.bind<TokensService>({ provide: TokensService, useValue: tokensService })
.bind<UsersRepository>({ provide: UsersRepository, useValue: usersRepository })
.bind<UserRolesService>({ provide: UserRolesService, useValue: userRolesService })
.bind<WishlistsService>({ provide: WishlistsService, useValue: wishlistsService })
.bind<CollectionsService>({ provide: CollectionsService, useValue: collectionsService });
beforeAll(() => {
container
.bind<CredentialsRepository>({ provide: CredentialsRepository, useValue: credentialsRepository })
.bind<DrizzleService>({ provide: DrizzleService, useValue: drizzleService })
.bind<TokensService>({ provide: TokensService, useValue: tokensService })
.bind<UsersRepository>({ provide: UsersRepository, useValue: usersRepository })
.bind<UserRolesService>({ provide: UserRolesService, useValue: userRolesService })
.bind<WishlistsService>({ provide: WishlistsService, useValue: wishlistsService })
.bind<CollectionsService>({ provide: CollectionsService, useValue: collectionsService });
service = container.get(UsersService);
service = container.get(UsersService);
drizzleService.db = {
transaction: vi.fn().mockImplementation(async (callback) => {
return await callback();
}),
} as any;
});
drizzleService.db = {
transaction: vi.fn().mockImplementation(async (callback) => {
return await callback();
}),
} as any;
});
afterAll(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
const timeStampDate = new Date();
const dbUser = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
verified: false,
receive_email: false,
mfa_enabled: false,
theme: 'system',
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const dbCredentials = {
id: faker.string.uuid(),
user_id: dbUser.id,
type: CredentialsType.PASSWORD,
secret_data: 'hashedPassword',
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const timeStampDate = new Date();
const dbUser = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
verified: false,
receive_email: false,
mfa_enabled: false,
theme: 'system',
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
const dbCredentials = {
id: faker.string.uuid(),
user_id: dbUser.id,
type: CredentialsType.PASSWORD,
secret_data: 'hashedPassword',
createdAt: timeStampDate,
updatedAt: timeStampDate,
};
describe('Create User', () => {
it('should resolve', async () => {
const hashedPassword = 'testhash';
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword);
describe('Create User', () => {
it('should resolve', async () => {
const hashedPassword = 'testhash';
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword);
drizzleService.db.transaction = vi.fn().mockImplementation(async (callback) => {
return dbUser satisfies Awaited<ReturnType<typeof callback>>
});
drizzleService.db.transaction = vi.fn().mockImplementation(async (callback) => {
return dbUser satisfies Awaited<ReturnType<typeof callback>>;
});
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const createdUser = await service.create({
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
password: faker.string.alphanumeric(10),
confirm_password: faker.string.alphanumeric(10),
});
expect(createdUser).toEqual(dbUser);
expect(spy_tokensService_createHashToken).toBeCalledTimes(1);
});
});
describe('Update User', () => {
it('should resolve Password Exiting Credentials', async () => {
const hashedPassword = 'testhash';
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword);
credentialsRepository.update = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.update>>);
credentialsRepository.findPasswordCredentialsByUserId = vi
.fn()
.mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.findPasswordCredentialsByUserId>>);
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const createdUser = await service.create({
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
password: faker.string.alphanumeric(10),
confirm_password: faker.string.alphanumeric(10),
});
expect(createdUser).toEqual(dbUser);
expect(spy_tokensService_createHashToken).toBeCalledTimes(1);
});
});
describe('Update User', () => {
it('should resolve Password Exiting Credentials', async () => {
const hashedPassword = 'testhash';
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword);
credentialsRepository.update = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.update>>);
credentialsRepository.findPasswordCredentialsByUserId = vi
.fn()
.mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.findPasswordCredentialsByUserId>>);
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId');
const spy_credentialsRepository_update = vi.spyOn(credentialsRepository, 'update');
await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.toBeUndefined();
expect(spy_tokensService_createHashToken).toBeCalledTimes(1);
expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1);
expect(spy_credentialsRepository_update).toBeCalledTimes(1);
});
it('Should Create User Password No Existing Credentials', async () => {
const hashedPassword = 'testhash';
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword);
credentialsRepository.findPasswordCredentialsByUserId = vi.fn().mockResolvedValue(null);
credentialsRepository.create = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.create>>);
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId');
const spy_credentialsRepository_update = vi.spyOn(credentialsRepository, 'update');
await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.toBeUndefined();
expect(spy_tokensService_createHashToken).toBeCalledTimes(1);
expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1);
expect(spy_credentialsRepository_update).toBeCalledTimes(1);
});
it('Should Create User Password No Existing Credentials', async () => {
const hashedPassword = 'testhash';
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword);
credentialsRepository.findPasswordCredentialsByUserId = vi.fn().mockResolvedValue(null);
credentialsRepository.create = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.create>>);
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create');
const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId');
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create');
const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId');
await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.not.toThrow();
expect(spy_tokensService_createHashToken).toBeCalledTimes(1);
expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1);
expect(spy_credentialsRepository_create).toHaveBeenCalledTimes(1);
});
});
await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.not.toThrow();
expect(spy_tokensService_createHashToken).toBeCalledTimes(1);
expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1);
expect(spy_credentialsRepository_create).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,70 @@
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import { CredentialsType, credentialsTable } from '$lib/server/api/databases/postgres/tables/credentials.table';
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, and, eq } from 'drizzle-orm';
import { takeFirstOrThrow } from '../common/utils/repository';
export type CreateCredentials = InferInsertModel<typeof credentialsTable>;
export type UpdateCredentials = Partial<CreateCredentials>;
export type DeleteCredentials = Pick<CreateCredentials, 'id'>;
@injectable()
export class CredentialsRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneByUserId(userId: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: eq(credentialsTable.user_id, userId),
});
}
async findOneByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type)),
});
}
async findPasswordCredentialsByUserId(userId: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.PASSWORD)),
});
}
async findTOTPCredentialsByUserId(userId: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.TOTP)),
});
}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.credentialsTable.findFirst({
where: eq(credentialsTable.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const credentials = await this.findOneById(id, db);
if (!credentials) throw Error('Credentials not found');
return credentials;
}
async create(data: CreateCredentials, db = this.drizzle.db) {
return db.insert(credentialsTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateCredentials, db = this.drizzle.db) {
return db.update(credentialsTable).set(data).where(eq(credentialsTable.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(credentialsTable).where(eq(credentialsTable.id, id));
}
async deleteByUserId(userId: string, db = this.drizzle.db) {
return db.delete(credentialsTable).where(eq(credentialsTable.user_id, userId));
}
async deleteByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) {
return db.delete(credentialsTable).where(and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type)));
}
}

View file

@ -0,0 +1,14 @@
import type { z } from 'zod';
import { userDto } from './user.dto';
export const updateUserDto = userDto
.pick({
firstName: true,
lastName: true,
email: true,
username: true,
avatar: true,
})
.optional();
export type UpdateUserDto = z.infer<typeof updateUserDto>;

View file

@ -0,0 +1,23 @@
import { z } from 'zod';
const MAX_UPLOAD_SIZE = 1024 * 1024 * 3; // 3MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
export const userDto = z.object({
id: z.string(),
firstName: z.string().trim().optional(),
lastName: z.string().trim().optional(),
email: z.string().trim().max(64, { message: 'Email must be less than 64 characters' }).optional(),
username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
avatar: z
.instanceof(File)
.optional()
.refine((file) => {
return !file || file.size <= MAX_UPLOAD_SIZE;
}, 'File size must be less than 3MB')
.refine((file) => {
return ACCEPTED_FILE_TYPES.includes(file!.type);
}, 'File must be a PNG'),
});
export type UserDto = z.infer<typeof userDto>;

View file

@ -0,0 +1,28 @@
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, and, eq } from 'drizzle-orm';
import { takeFirstOrThrow } from '../common/utils/repository';
import { DrizzleService } from '../databases/postgres/drizzle.service';
import { federatedIdentityTable } from '../databases/postgres/tables';
export type CreateFederatedIdentity = InferInsertModel<typeof federatedIdentityTable>;
@injectable()
export class FederatedIdentityRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneByUserIdAndProvider(userId: string, provider: string) {
return this.drizzle.db.query.federatedIdentityTable.findFirst({
where: and(eq(federatedIdentityTable.user_id, userId), eq(federatedIdentityTable.identity_provider, provider)),
});
}
async findOneByFederatedUserIdAndProvider(federatedUserId: string, provider: string) {
return this.drizzle.db.query.federatedIdentityTable.findFirst({
where: and(eq(federatedIdentityTable.federated_user_id, federatedUserId), eq(federatedIdentityTable.identity_provider, provider)),
});
}
async create(data: CreateFederatedIdentity, db = this.drizzle.db) {
return db.insert(federatedIdentityTable).values(data).returning().then(takeFirstOrThrow);
}
}

View file

@ -0,0 +1,32 @@
import { takeFirstOrThrow } from '$lib/server/api/common/utils/repository';
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, and, eq } from 'drizzle-orm';
import { recoveryCodesTable } from '../databases/postgres/tables';
export type CreateRecoveryCodes = InferInsertModel<typeof recoveryCodesTable>;
@injectable()
export class RecoveryCodesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async create(data: CreateRecoveryCodes, db = this.drizzle.db) {
return db.insert(recoveryCodesTable).values(data).returning().then(takeFirstOrThrow);
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.recoveryCodesTable.findMany({
where: eq(recoveryCodesTable.userId, userId),
});
}
async findAllNotUsedByUserId(userId: string, db = this.drizzle.db) {
return db.query.recoveryCodesTable.findMany({
where: and(eq(recoveryCodesTable.userId, userId), eq(recoveryCodesTable.used, false)),
});
}
async deleteAllByUserId(userId: string, db = this.drizzle.db) {
return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId));
}
}

View file

@ -0,0 +1,40 @@
import { RecoveryCodesRepository } from '$lib/server/api/users/recovery-codes.repository';
import { inject, injectable } from '@needle-di/core';
import { alphabet, generateRandomString } from 'oslo/crypto';
import { HashingService } from '../common/services/hashing.service';
@injectable()
export class RecoveryCodesService {
constructor(
private hashingService = inject(HashingService),
private recoveryCodesRepository = inject(RecoveryCodesRepository),
) {}
async findAllRecoveryCodesByUserId(userId: string) {
return this.recoveryCodesRepository.findAllByUserId(userId);
}
async createRecoveryCodes(userId: string) {
const createdRecoveryCodes = Array.from({ length: 5 }, () => generateRandomString(10, alphabet('A-Z', '0-9')));
if (createdRecoveryCodes && userId) {
for (const code of createdRecoveryCodes) {
const hashedCode = await this.hashingService.hash(code);
console.log('Inserting recovery code', code, hashedCode);
await this.recoveryCodesRepository.create({ userId, code: hashedCode });
}
return createdRecoveryCodes;
}
return [];
}
async verify(userId: string, code: string) {
const recoveryCodes = await this.recoveryCodesRepository.findAllNotUsedByUserId(userId);
return recoveryCodes.find((recoveryCode) => this.hashingService.verify(recoveryCode.code, code));
}
async deleteAllRecoveryCodesByUserId(userId: string) {
return this.recoveryCodesRepository.deleteAllByUserId(userId);
}
}

View file

@ -0,0 +1,53 @@
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, eq } from 'drizzle-orm';
import { takeFirstOrThrow } from '../common/utils/repository';
import { rolesTable } from '../databases/postgres/tables';
export type CreateRole = InferInsertModel<typeof rolesTable>;
export type UpdateRole = Partial<CreateRole>;
@injectable()
export class RolesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.rolesTable.findFirst({
where: eq(rolesTable.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const role = await this.findOneById(id, db);
if (!role) throw Error('Role not found');
return role;
}
async findAll(db = this.drizzle.db) {
return db.query.rolesTable.findMany();
}
async findOneByName(name: string, db = this.drizzle.db) {
return db.query.rolesTable.findFirst({
where: eq(rolesTable.name, name),
});
}
async findOneByNameOrThrow(name: string, db = this.drizzle.db) {
const role = await this.findOneByName(name, db);
if (!role) throw Error('Role not found');
return role;
}
async create(data: CreateRole, db = this.drizzle.db) {
return db.insert(rolesTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateRole, db = this.drizzle.db) {
return db.update(rolesTable).set(data).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(rolesTable).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow);
}
}

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