From 73a6b01ed7d333827e85dcf1bcc8d1da621b260d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 18 Apr 2026 23:24:24 -0300 Subject: [PATCH 01/15] feat(modules/auth): semi-intergation of auth module --- modules/auth/controllers/index.controller.ts | 24 ++++++++++++++++++++ modules/auth/schemas/signin.schema.ts | 9 ++++++++ 2 files changed, 33 insertions(+) create mode 100644 modules/auth/schemas/signin.schema.ts diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index b94a616..fac91cf 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -5,12 +5,14 @@ import jwt from "jsonwebtoken"; import { ZodError } from "zod"; import jwtConfig from "@/config/jwt"; +import prisma from "@/database/prisma/client"; import redisClient from "@/database/redis/client"; import NotFoundError from "../exceptions/notfound.exception"; import UnauthorizedError from "../exceptions/unauthorized.exception"; import CredentialSchema from "../schemas/credential.schema"; import RefreshTokenSchema from "../schemas/refresh-token.schema"; +import SignInSchema from "../schemas/signin.schema"; import type { JwtSubject } from "../types/jwt"; export default { @@ -87,6 +89,28 @@ export default { } }, + async signin(req: FastifyRequest, reply: FastifyReply) { + try { + const { password, ...credentials } = SignInSchema.parse(req.body); + + const userCounter = await prisma.user.count({ + where: { + email: credentials.email, + }, + }); + + if (userCounter > 0) { + throw new UnauthorizedError(req.t("Email already in use")); + } + } catch (e) { + if (e instanceof UnauthorizedError) { + reply.status(401).send({ error: e.message }); + } else { + reply.status(500).send({ error: req.t("Server error") }); + } + } + }, + logout(req: FastifyRequest, reply: FastifyReply) { try { const bearerToken = req.headers.authorization; diff --git a/modules/auth/schemas/signin.schema.ts b/modules/auth/schemas/signin.schema.ts new file mode 100644 index 0000000..495a413 --- /dev/null +++ b/modules/auth/schemas/signin.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +const SignInSchema = z.object({ + email: z.email(), + username: z.string().min(3).max(30), + password: z.string().min(8), +}); + +export default SignInSchema; From 48baa6401786ee6822a22e3a1ef6303ec2e4790e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Fri, 1 May 2026 00:41:29 -0300 Subject: [PATCH 02/15] feat: signup + login fixed --- .env.example | 18 +- @types/i18next.d.ts | 3 + bun.lock | 10 + ci/Dockerfile.dev | 16 ++ compose.yml | 58 ++++ config/i18n.ts | 6 +- database/prisma/schema.prisma | 10 +- index.ts | 36 +++ locales/en-US/zod.json | 3 + locales/es-ES/errors.json | 3 +- locales/es-ES/zod.json | 3 + locales/fr-FR/zod.json | 1 + locales/pt-BR/zod.json | 1 + modules/auth/controllers/index.controller.ts | 269 ++++++++++-------- modules/auth/exceptions/conflict.exception.ts | 8 + modules/auth/exceptions/index.exception.ts | 3 + modules/auth/exceptions/notfound.exception.ts | 4 +- .../auth/exceptions/unauthorized.exception.ts | 4 +- modules/auth/routes/index.router.ts | 8 +- modules/auth/schemas/credential.schema.ts | 4 +- modules/auth/schemas/index.schema.ts | 4 + modules/auth/schemas/refresh-token.schema.ts | 4 +- modules/auth/schemas/signin.schema.ts | 4 +- modules/auth/schemas/signup.schema.ts | 18 ++ modules/auth/serivces/jwt.service.ts | 39 +++ modules/users/controllers/index.controller.ts | 20 ++ package.json | 2 + plugins/i18n.ts | 4 + plugins/jwt.ts | 62 ++++ plugins/zod.ts | 7 + 30 files changed, 478 insertions(+), 154 deletions(-) create mode 100644 @types/i18next.d.ts create mode 100644 ci/Dockerfile.dev create mode 100644 compose.yml create mode 100644 locales/en-US/zod.json create mode 100644 locales/es-ES/zod.json create mode 100644 locales/fr-FR/zod.json create mode 100644 locales/pt-BR/zod.json create mode 100644 modules/auth/exceptions/conflict.exception.ts create mode 100644 modules/auth/exceptions/index.exception.ts create mode 100644 modules/auth/schemas/index.schema.ts create mode 100644 modules/auth/schemas/signup.schema.ts create mode 100644 modules/auth/serivces/jwt.service.ts create mode 100644 plugins/jwt.ts create mode 100644 plugins/zod.ts diff --git a/.env.example b/.env.example index a30630f..9bb2dca 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ -NODE_ENV=development +NODE_ENV= -DATABASE_URL=${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_SCHEMA} +POSTGRES_USERNAME= +POSTGRES_DATABASE= +POSTGRES_PASSWORD= +POSTGRES_HOST= +POSTGRES_PORT= -DB_DRIVER= -DB_USER= -DB_SCHEMA= -DB_PASSWORD= -DB_HOST= -DB_PORT= +REDIS_HOST= +REDIS_PORT= +REDIS_USERNAME= +REDIS_PASSWORD= diff --git a/@types/i18next.d.ts b/@types/i18next.d.ts new file mode 100644 index 0000000..7ccbc6e --- /dev/null +++ b/@types/i18next.d.ts @@ -0,0 +1,3 @@ +declare namespace i18next { + export type TFunction = (key: string, options?: unknown) => string; +} diff --git a/bun.lock b/bun.lock index 8c6848d..48432a2 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", "i18next": "^26.0.3", @@ -21,6 +22,7 @@ "@biomejs/biome": "2.4.10", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.11", "@types/jsonwebtoken": "^9.0.10", "@typescript/native-preview": "^7.0.0-dev.20260403.1", @@ -161,6 +163,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], @@ -209,6 +213,8 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], + "better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -437,6 +443,10 @@ "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], diff --git a/ci/Dockerfile.dev b/ci/Dockerfile.dev new file mode 100644 index 0000000..d6b6795 --- /dev/null +++ b/ci/Dockerfile.dev @@ -0,0 +1,16 @@ +FROM oven/bun:1 +WORKDIR /usr/src/app + +COPY package.json bun.lock ./ +ENV CI=true +RUN bun install + +COPY . . +RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" \ + bunx --bun prisma generate + +ENV NODE_ENV=development + +USER bun +EXPOSE 8080/tcp +CMD [ "bun", "dev" ] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..6a67162 --- /dev/null +++ b/compose.yml @@ -0,0 +1,58 @@ +name: dojoh-api + +services: + api: + container_name: dojoh-api + image: dojoh-api:latest + build: + dockerfile: ci/Dockerfile.dev + context: . + ports: + - "8080:8080" + environment: + - NODE_ENV=development + networks: + - dojoh-net + volumes: + - ./:/usr/src/app + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - pg + - redis + + pg: + container_name: dojoh-pg + image: postgres:18 + environment: + - POSTGRES_USER=dojoh + - POSTGRES_PASSWORD=my_secret_password + - POSTGRES_DB=dojoh_dev + ports: + - "5432:5432" + networks: + - dojoh-net + volumes: + - pg_data:/var/lib/postgresql/18/docker + + redis: + container_name: dojoh-redis + image: redis:7 + ports: + - "6379:6379" + environment: + - REDIS_PASSWORD=my_secret_password + command: > + sh -c "redis-server --requirepass ${REDIS_PASSWORD}" + networks: + - dojoh-net + +networks: + dojoh-net: + driver: bridge + +volumes: + pg_data: {} diff --git a/config/i18n.ts b/config/i18n.ts index f37c9fc..ccd6a39 100644 --- a/config/i18n.ts +++ b/config/i18n.ts @@ -1,9 +1,11 @@ import path from "node:path"; +import type * as i18next from "i18next"; + export default { fallbackLng: "en-US", supportedLngs: ["en-US", "es-ES", "fr-FR", "pt-BR"], - ns: ["messages", "errors"], + ns: ["messages", "errors", "zod"], defaultNS: "messages", backend: { loadPath: path.join(process.cwd(), "locales/{{lng}}/{{ns}}.json"), @@ -11,4 +13,4 @@ export default { interpolation: { escapeValue: false, }, -}; +} satisfies i18next.InitOptions; diff --git a/database/prisma/schema.prisma b/database/prisma/schema.prisma index a76fc18..29b918a 100644 --- a/database/prisma/schema.prisma +++ b/database/prisma/schema.prisma @@ -43,11 +43,11 @@ model ImageVariant { } model User { - id Int @id @default(autoincrement()) - email String @unique - nickname String @unique - password String? - avatar Image? @relation(fields: [avatar_id], references: [id]) + id Int @id @default(autoincrement()) + email String @unique + nickname String @unique + password String + avatar Image? @relation(fields: [avatar_id], references: [id]) avatar_id Int? created_at DateTime @default(now()) diff --git a/index.ts b/index.ts index ae89b62..10d797c 100644 --- a/index.ts +++ b/index.ts @@ -2,12 +2,14 @@ import Fastify from "fastify"; import env from "./config/env"; import i18nPlugin from "./plugins/i18n"; +import jwtPlugin from "./plugins/jwt"; const f = Fastify({ logger: true, }); f.register(i18nPlugin); +f.register(jwtPlugin); f.get("/", async () => { return { message: "Hello World!" }; @@ -18,6 +20,40 @@ f.get("/health", async () => { }); f.post("/echo", async (request) => { + console.debug("[BODY]: ", request.body); + console.debug("[QUERY]: ", request.query); + console.debug("[PARAMS]: ", request.params); + console.debug("[HEADERS]: ", request.headers); + // console.debug("[RAW]: ", request.raw); + // console.debug("[SERVER]: ", request.server); + console.debug("[ID]: ", request.id); + console.debug("[IP]: ", request.ip); + console.debug("[IPS]: ", request.ips); + console.debug("[HOST]: ", request.host); + console.debug("[HOSTNAME]: ", request.hostname); + console.debug("[PORT]: ", request.port); + console.debug("[PROTOCOL]: ", request.protocol); + console.debug("[URL]: ", request.url); + console.debug("[ROUTE_METHOD]: ", request.routeOptions.method); + console.debug( + "[ROUTE_BODY_LIMIT]: ", + request.routeOptions.bodyLimit + ? `${(request.routeOptions.bodyLimit / (1024 * 1024)).toFixed(2)} MB` + : "N/A", + ); + console.debug("[ROUTE_URL]: ", request.routeOptions.url); + console.debug( + "[ROUTE_ATTACH_VALIDATION]: ", + request.routeOptions.attachValidation, + ); + console.debug("[ROUTE_LOG_LEVEL]: ", request.routeOptions.logLevel); + console.debug("[ROUTE_VERSION]: ", request.routeOptions.version); + console.debug("[ROUTE_EXPOSE_HEAD]: ", request.routeOptions.exposeHeadRoute); + console.debug( + "[ROUTE_PREFIX_TRAILING_SLASH]: ", + request.routeOptions.prefixTrailingSlash, + ); + return { received: request.body }; }); diff --git a/locales/en-US/zod.json b/locales/en-US/zod.json new file mode 100644 index 0000000..ad246a9 --- /dev/null +++ b/locales/en-US/zod.json @@ -0,0 +1,3 @@ +{ + "nickname_rules": "The nickname must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed." +} diff --git a/locales/es-ES/errors.json b/locales/es-ES/errors.json index 3ea337d..124ce7d 100644 --- a/locales/es-ES/errors.json +++ b/locales/es-ES/errors.json @@ -1,4 +1,5 @@ { "Invalid credentials": "Credenciales inválidas", - "Server error": "Error del servidor" + "Server error": "Error del servidor", + "Email already in use": "El correo electrónico ya está en uso" } diff --git a/locales/es-ES/zod.json b/locales/es-ES/zod.json new file mode 100644 index 0000000..bee87a1 --- /dev/null +++ b/locales/es-ES/zod.json @@ -0,0 +1,3 @@ +{ + "nickname_rules": "El apodo debe comenzar y terminar con un carácter alfanumérico, y puede contener puntos, guiones bajos o guiones en el medio. No se permiten puntos, guiones bajos o guiones consecutivos." +} diff --git a/locales/fr-FR/zod.json b/locales/fr-FR/zod.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/locales/fr-FR/zod.json @@ -0,0 +1 @@ +{} diff --git a/locales/pt-BR/zod.json b/locales/pt-BR/zod.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/locales/pt-BR/zod.json @@ -0,0 +1 @@ +{} diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index fac91cf..0a2d16f 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import bcrypt from "bcrypt"; import type { FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; import { ZodError } from "zod"; @@ -8,59 +9,116 @@ import jwtConfig from "@/config/jwt"; import prisma from "@/database/prisma/client"; import redisClient from "@/database/redis/client"; -import NotFoundError from "../exceptions/notfound.exception"; -import UnauthorizedError from "../exceptions/unauthorized.exception"; -import CredentialSchema from "../schemas/credential.schema"; -import RefreshTokenSchema from "../schemas/refresh-token.schema"; -import SignInSchema from "../schemas/signin.schema"; +import { + ConflictError, + NotFoundError, + UnauthorizedError, +} from "../exceptions/index.exception"; +import { + CredentialSchema, + createSignUpSchema, + RefreshTokenSchema, +} from "../schemas/index.schema"; +import { createTokens } from "../serivces/jwt.service"; import type { JwtSubject } from "../types/jwt"; export default { - login(req: FastifyRequest, reply: FastifyReply) { + async signUp(req: FastifyRequest, reply: FastifyReply) { try { - const { password, ...credentials } = CredentialSchema.parse(req.body); - - // @todo: validate credentials with database - - const user = { - id: crypto.randomUUID(), - email: credentials.email, - username: "@john_doe", - name: "John Doe", - avatarUrl: "https://example.com/avatar.jpg", - roles: ["player"], - }; - - const token = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, + const Schema = createSignUpSchema(req.t); + + const { password, ...credentials } = Schema.parse(req.body); + + const user = await prisma.user.findUnique({ + where: { + email: credentials.email, }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); + }); - const refreshToken = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, - type: "refresh", - tokenId: crypto.randomUUID(), + if (user) { + throw new ConflictError( + req.t("Email already in use", { ns: "errors" }), + ); + } + + const newUser = await prisma.user.create({ + data: { + email: credentials.email, + nickname: credentials.nickname, + password: await bcrypt.hash(password, 10), }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); + select: { + id: true, + email: true, + nickname: true, + }, + }); + + const tokens = createTokens({ + id: newUser.id, + email: newUser.email, + nickname: newUser.nickname, + }); + + return reply.status(201).send({ + message: req.t("User created successfully"), + data: newUser, + tokens, + }); + } catch (e) { + if (e instanceof ZodError) { + return reply.status(400).send({ + error: req.t("Invalid credentials", { ns: "errors" }), + details: e.issues, + }); + } + + if (e instanceof ConflictError) { + return reply.status(e.statusCode).send({ error: e.message }); + } - const response = { - user, - tokens: { - token, - refreshToken, - expiresIn: 60 * 60, // 1 hour in seconds + console.debug((e as Error).message); + return reply + .status(500) + .send({ error: req.t("Server error", { ns: "errors" }) }); + } + }, + + async logInViaSSO(req: FastifyRequest, reply: FastifyReply) { + try { + const credentials = CredentialSchema.parse(req.body); + + const { password, ...user } = await prisma.user.findFirst({ + where: { + email: credentials.email, + }, + select: { + id: true, + email: true, + nickname: true, + password: true, + avatar: true, }, - }; + }); + + if (!user) { + throw new NotFoundError(req.t("User not found")); + } + + const authenticated = await bcrypt.compare( + credentials.password, + password, + ); + + if (!authenticated) { + throw new UnauthorizedError(req.t("Invalid credentials")); + } + + const tokens = createTokens({ + id: user.id, + email: user.email, + nickname: user.nickname, + }); redisClient.set( `session:${user.id}`, @@ -74,44 +132,37 @@ export default { 1 * 24 * 60 * 60, // session long 1 day max ); - reply.send({ message: req.t("Login successful"), data: response }); + reply.send({ + message: req.t("Login successful"), + data: user, + tokens, + }); } catch (e) { - console.debug((e as Error).message); - if (e instanceof ZodError) { - reply.status(400).send({ + return reply.status(400).send({ error: req.t("Invalid credentials"), details: e.issues, }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } - } - }, - - async signin(req: FastifyRequest, reply: FastifyReply) { - try { - const { password, ...credentials } = SignInSchema.parse(req.body); - const userCounter = await prisma.user.count({ - where: { - email: credentials.email, - }, - }); - - if (userCounter > 0) { - throw new UnauthorizedError(req.t("Email already in use")); - } - } catch (e) { if (e instanceof UnauthorizedError) { - reply.status(401).send({ error: e.message }); - } else { - reply.status(500).send({ error: req.t("Server error") }); + return reply.status(e.statusCode).send({ + error: e.message, + details: { + email: req.t("Check your email", { ns: "errors" }), + password: req.t("Check your password", { + ns: "errors", + }), + }, + }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, - logout(req: FastifyRequest, reply: FastifyReply) { + logOut(req: FastifyRequest, reply: FastifyReply) { try { const bearerToken = req.headers.authorization; if (!bearerToken) { @@ -162,65 +213,30 @@ export default { throw new UnauthorizedError(req.t("Invalid token type")); } - const user = { - id: crypto.randomUUID(), - email: (decoded.sub as unknown as { email: string }).email, - username: "@john_doe", - name: "John Doe", - avatarUrl: "https://example.com/avatar.jpg", - roles: ["player"], - }; - - const newToken = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, - }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); - - const newRefreshToken = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, - type: "refresh", - tokenId: crypto.randomUUID(), - }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); - - const response = { - tokens: { - token: newToken, - refreshToken: newRefreshToken, - expiresIn: 60 * 60, // 1 hour in seconds - }, - }; + const tokens = createTokens(decoded.sub); reply.send({ message: req.t("Token refreshed successfully"), - data: response, + tokens, + data: decoded.sub, }); } catch (e) { - console.debug((e as Error).message); - if (e instanceof ZodError) { - reply.status(400).send({ + return reply.status(400).send({ error: req.t("Invalid request"), details: e.issues, }); - } else if (e instanceof jwt.JsonWebTokenError) { - reply.status(401).send({ + } + + if (e instanceof jwt.JsonWebTokenError) { + return reply.status(401).send({ error: req.t("Invalid refresh token"), details: e.message, }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, @@ -258,25 +274,28 @@ export default { reply.send({ message: req.t("Session revoked") }); } catch (e) { - console.debug((e as Error).message); - if (e instanceof ZodError) { - reply.status(400).send({ + return reply.status(400).send({ error: req.t("Invalid request"), details: e.issues, }); - } else if (e instanceof NotFoundError) { - reply.status(404).send({ error: e.message }); - } else if ( + } + + if (e instanceof NotFoundError) { + return reply.status(404).send({ error: e.message }); + } + + if ( e instanceof jwt.JsonWebTokenError || e instanceof UnauthorizedError ) { - reply + return reply .status(401) .send({ error: req.t("Unauthorized"), details: e.message }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, } as const; diff --git a/modules/auth/exceptions/conflict.exception.ts b/modules/auth/exceptions/conflict.exception.ts new file mode 100644 index 0000000..9e649ed --- /dev/null +++ b/modules/auth/exceptions/conflict.exception.ts @@ -0,0 +1,8 @@ +export class ConflictError extends Error { + statusCode: number; + constructor(message: string) { + super(message); + this.name = "ConflictError"; + this.statusCode = 409; + } +} diff --git a/modules/auth/exceptions/index.exception.ts b/modules/auth/exceptions/index.exception.ts new file mode 100644 index 0000000..32d52e6 --- /dev/null +++ b/modules/auth/exceptions/index.exception.ts @@ -0,0 +1,3 @@ +export * from "./conflict.exception"; +export * from "./notfound.exception"; +export * from "./unauthorized.exception"; diff --git a/modules/auth/exceptions/notfound.exception.ts b/modules/auth/exceptions/notfound.exception.ts index 5e68bac..da934cb 100644 --- a/modules/auth/exceptions/notfound.exception.ts +++ b/modules/auth/exceptions/notfound.exception.ts @@ -1,6 +1,8 @@ -export default class NotFoundError extends Error { +export class NotFoundError extends Error { + statusCode: number; constructor(message: string) { super(message); this.name = "NotFoundError"; + this.statusCode = 404; } } diff --git a/modules/auth/exceptions/unauthorized.exception.ts b/modules/auth/exceptions/unauthorized.exception.ts index 29b1ddb..432bd60 100644 --- a/modules/auth/exceptions/unauthorized.exception.ts +++ b/modules/auth/exceptions/unauthorized.exception.ts @@ -1,6 +1,8 @@ -export default class UnauthorizedError extends Error { +export class UnauthorizedError extends Error { + statusCode: number; constructor(message: string) { super(message); this.name = "UnauthorizedError"; + this.statusCode = 401; } } diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index 0167f61..aba98b8 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -3,8 +3,12 @@ import type { FastifyInstance } from "fastify"; import AuthController from "@/modules/auth/controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { - f.post("/login", AuthController.login); - f.get("/logout", AuthController.logout); + f.post("/signup", AuthController.signUp); + f.post("/login/sso", AuthController.logInViaSSO); + // f.post("/login/google", AuthController.login); + // f.post("/login/github", AuthController.login); + // f.post("/login/discord", AuthController.login); f.post("/refresh", AuthController.refresh); f.delete("/revoke", AuthController.revoke); + f.get("/logout", AuthController.logOut); } diff --git a/modules/auth/schemas/credential.schema.ts b/modules/auth/schemas/credential.schema.ts index a8d8478..58cf749 100644 --- a/modules/auth/schemas/credential.schema.ts +++ b/modules/auth/schemas/credential.schema.ts @@ -1,8 +1,6 @@ import { z } from "zod"; -const CredentialSchema = z.object({ +export const CredentialSchema = z.object({ email: z.email(), password: z.string().min(8), }); - -export default CredentialSchema; diff --git a/modules/auth/schemas/index.schema.ts b/modules/auth/schemas/index.schema.ts new file mode 100644 index 0000000..03e6df2 --- /dev/null +++ b/modules/auth/schemas/index.schema.ts @@ -0,0 +1,4 @@ +export * from "./credential.schema"; +export * from "./refresh-token.schema"; +export * from "./signin.schema"; +export * from "./signup.schema"; diff --git a/modules/auth/schemas/refresh-token.schema.ts b/modules/auth/schemas/refresh-token.schema.ts index 8b041fe..a35bd0f 100644 --- a/modules/auth/schemas/refresh-token.schema.ts +++ b/modules/auth/schemas/refresh-token.schema.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -const RefreshTokenSchema = z.object({ +export const RefreshTokenSchema = z.object({ refreshToken: z.string(), }); - -export default RefreshTokenSchema; diff --git a/modules/auth/schemas/signin.schema.ts b/modules/auth/schemas/signin.schema.ts index 495a413..d6788b2 100644 --- a/modules/auth/schemas/signin.schema.ts +++ b/modules/auth/schemas/signin.schema.ts @@ -1,9 +1,7 @@ import { z } from "zod"; -const SignInSchema = z.object({ +export const SignInSchema = z.object({ email: z.email(), username: z.string().min(3).max(30), password: z.string().min(8), }); - -export default SignInSchema; diff --git a/modules/auth/schemas/signup.schema.ts b/modules/auth/schemas/signup.schema.ts new file mode 100644 index 0000000..9444b7a --- /dev/null +++ b/modules/auth/schemas/signup.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const createSignUpSchema = (t: i18next.TFunction) => { + return z.object({ + email: z.email(), + nickname: z + .string() + // Must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed. + // Valid: "john_doe", "john.doe", "john-doe", "john123" + // Invalid: "_john", "john_", "john..doe", "john__doe", "john--doe", "john.-doe", "john-.doe" + .regex(/^[a-zA-Z0-9]([._-](?![._-])|[a-zA-Z0-9]){1,14}[a-zA-Z0-9]$/, { + error: t("nickname_rules", { ns: "zod" }), + }) + .min(3) + .max(30), + password: z.string().min(8), + }); +}; diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts new file mode 100644 index 0000000..58351e7 --- /dev/null +++ b/modules/auth/serivces/jwt.service.ts @@ -0,0 +1,39 @@ +import crypto from "node:crypto"; + +import jwt from "jsonwebtoken"; + +import jwtConfig from "@/config/jwt"; + +export const createTokens = (sub: unknown) => { + const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour expiration + const iat = Math.floor(Date.now() / 1000); + + const accessToken = jwt.sign( + { + exp, + iat, + sub, + }, + jwtConfig.secret, + { algorithm: jwtConfig.algorithm }, + ); + + const refreshToken = jwt.sign( + { + exp, + iat, + sub, + type: "refresh", + tokenId: crypto.randomUUID(), + }, + jwtConfig.secret, + { algorithm: jwtConfig.algorithm }, + ); + + return { + accessToken, + refreshToken, + expiresIn: 60 * 60, // 1 hour in seconds + issuedAt: iat, + }; +}; diff --git a/modules/users/controllers/index.controller.ts b/modules/users/controllers/index.controller.ts index e69de29..210ba6b 100644 --- a/modules/users/controllers/index.controller.ts +++ b/modules/users/controllers/index.controller.ts @@ -0,0 +1,20 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + +import prisma from "@/database/prisma/client"; + +// export default { +// async uploadAvatar(req: FastifyRequest, reply: FastifyReply) { +// const userId = req.user.id; + +// if (!req.file) { +// return reply.status(400).send({ error: req.t("No file uploaded") }); +// } + +// const avatarUrl = `/uploads/${req.file.filename}`; + +// try { +// await prisma.image.create({ + +// }); +// } +// } as const; diff --git a/package.json b/package.json index ded3ad5..08d7f82 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", "i18next": "^26.0.3", @@ -38,6 +39,7 @@ "@biomejs/biome": "2.4.10", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.11", "@types/jsonwebtoken": "^9.0.10", "@typescript/native-preview": "^7.0.0-dev.20260403.1", diff --git a/plugins/i18n.ts b/plugins/i18n.ts index b800d35..ba634c2 100644 --- a/plugins/i18n.ts +++ b/plugins/i18n.ts @@ -27,9 +27,13 @@ export default fp(async (f) => { // expose t() in Fastify request // @ts-expect-error f.decorateRequest("t", null); + // @ts-expect-error + f.decorateRequest("language", null); f.addHook("preHandler", async (req) => { // @ts-expect-error req.t = req.raw.t; + // @ts-expect-error + req.language = req.raw.language; }); }); diff --git a/plugins/jwt.ts b/plugins/jwt.ts new file mode 100644 index 0000000..dd2459c --- /dev/null +++ b/plugins/jwt.ts @@ -0,0 +1,62 @@ +import fp from "fastify-plugin"; +import jwt from "jsonwebtoken"; + +import jwtConfig from "@/config/jwt"; +import redisClient from "@/database/redis/client"; + +export default fp(async (f) => { + f.decorateRequest("user", null); + + f.addHook("preHandler", async (req, reply) => { + const { pathname } = new URL(req.url, `http://${req.headers.host}`); + + // Skip authentication for public routes + if (publicRoutes.some((route) => pathname.startsWith(route))) { + return; + } + + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new jwt.JsonWebTokenError(req.t("Header missing")); + } + + const token = authHeader.split(" ")[1]; + + if (!token) { + throw new jwt.JsonWebTokenError(req.t("No token provided")); + } + + try { + const decoded = jwt.verify(token, jwtConfig.secret, { + algorithms: [jwtConfig.algorithm], + }) as jwt.JwtPayload; + + const sub = decoded.sub as unknown as { + id: number; + email: string; + nickname: string; + }; + + const session = await redisClient.get(`session:${sub.id}`); + + if (!session) { + throw new jwt.JsonWebTokenError(req.t("Session expired or invalid")); + } + + // @ts-expect-error + req.user = JSON.parse(session); + } catch (e) { + if (e instanceof jwt.JsonWebTokenError) { + return reply + .status(401) + .send({ error: req.t("Unauthorized"), details: e.message }); + } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); + } + }); +}); + +const publicRoutes = ["/1/auth/signup", "/1/auth/login"]; diff --git a/plugins/zod.ts b/plugins/zod.ts new file mode 100644 index 0000000..83faf4d --- /dev/null +++ b/plugins/zod.ts @@ -0,0 +1,7 @@ +import fp from "fastify-plugin"; + +export default fp(async (f) => { + f.addHook("preHandler", async (req) => { + const lng = req.query; + }); +}); From d0c0c5abdd28a379668e2f4da8b585140d388f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 12:26:08 -0300 Subject: [PATCH 03/15] feat(Root): native login + google oauth2 login --- bun.lock | 79 ++++++ compose.yml | 1 + .../migrations/20260502141005/migration.sql | 14 + .../migrations/20260502151239/migration.sql | 5 + database/prisma/schema.prisma | 31 ++- database/redis/client.ts | 9 +- .../conflict.exception.ts | 0 .../index.exception.ts | 0 .../notfound.exception.ts | 0 .../unauthorized.exception.ts | 0 .../controllers/google-oauth.controller.ts | 202 +++++++++++++++ modules/auth/controllers/index.controller.ts | 244 +++++++++++++----- modules/auth/models/Auth.d.ts | 0 modules/auth/models/Session.d.ts | 14 + modules/auth/routes/index.router.ts | 24 +- modules/auth/schemas/credential.schema.ts | 19 +- modules/auth/schemas/google-user.schema.ts | 8 + modules/auth/schemas/index.schema.ts | 1 + modules/auth/serivces/google.service.ts | 1 + modules/auth/serivces/jwt.service.ts | 8 +- modules/auth/types/jwt.d.ts | 20 +- modules/users/models/User.d.ts | 6 + package.json | 1 + plugins/jwt.ts | 24 +- 24 files changed, 601 insertions(+), 110 deletions(-) create mode 100644 database/prisma/migrations/20260502141005/migration.sql create mode 100644 database/prisma/migrations/20260502151239/migration.sql rename {modules/auth/exceptions => exceptions}/conflict.exception.ts (100%) rename {modules/auth/exceptions => exceptions}/index.exception.ts (100%) rename {modules/auth/exceptions => exceptions}/notfound.exception.ts (100%) rename {modules/auth/exceptions => exceptions}/unauthorized.exception.ts (100%) create mode 100644 modules/auth/controllers/google-oauth.controller.ts delete mode 100644 modules/auth/models/Auth.d.ts create mode 100644 modules/auth/models/Session.d.ts create mode 100644 modules/auth/schemas/google-user.schema.ts create mode 100644 modules/auth/serivces/google.service.ts diff --git a/bun.lock b/bun.lock index 48432a2..b739060 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", + "googleapis": "^171.4.0", "i18next": "^26.0.3", "i18next-fs-backend": "^2.6.3", "i18next-http-middleware": "^3.9.2", @@ -195,6 +196,8 @@ "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -213,16 +216,24 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], "better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], @@ -255,6 +266,10 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -269,6 +284,8 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], @@ -281,10 +298,18 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -303,32 +328,62 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], "git-raw-commits": ["git-raw-commits@5.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="], "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "googleapis": ["googleapis@171.4.0", "", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg=="], + + "googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "i18next": ["i18next@26.0.8", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw=="], "i18next-fs-backend": ["i18next-fs-backend@2.6.5", "", {}, "sha512-+7HBrlaj3UFnNC9HqiXaIlNkwNINYkgQ2PS4h7qW/WGHC9/+OU9Xi0/tdvjjXATw3YCcpmNV7S3zDD9e10pTWg=="], @@ -365,6 +420,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], @@ -433,6 +490,8 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -445,8 +504,14 @@ "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -505,6 +570,8 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], @@ -555,6 +622,14 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -579,8 +654,12 @@ "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], diff --git a/compose.yml b/compose.yml index 6a67162..d68432e 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,7 @@ services: api: container_name: dojoh-api image: dojoh-api:latest + restart: unless-stopped build: dockerfile: ci/Dockerfile.dev context: . diff --git a/database/prisma/migrations/20260502141005/migration.sql b/database/prisma/migrations/20260502141005/migration.sql new file mode 100644 index 0000000..5f7a9c2 --- /dev/null +++ b/database/prisma/migrations/20260502141005/migration.sql @@ -0,0 +1,14 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "OAuthProvider" ADD VALUE 'DISCORD'; +ALTER TYPE "OAuthProvider" ADD VALUE 'NATIVE'; + +-- AlterTable +ALTER TABLE "OAuthAccount" ADD COLUMN "provider_metadata" JSONB, +ALTER COLUMN "provider_avatar_url" DROP NOT NULL; diff --git a/database/prisma/migrations/20260502151239/migration.sql b/database/prisma/migrations/20260502151239/migration.sql new file mode 100644 index 0000000..cdd9690 --- /dev/null +++ b/database/prisma/migrations/20260502151239/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "OAuthAccount" ALTER COLUMN "user_id" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "OAuthAccount" ADD CONSTRAINT "OAuthAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/database/prisma/schema.prisma b/database/prisma/schema.prisma index 29b918a..71b88b9 100644 --- a/database/prisma/schema.prisma +++ b/database/prisma/schema.prisma @@ -43,16 +43,17 @@ model ImageVariant { } model User { - id Int @id @default(autoincrement()) - email String @unique - nickname String @unique - password String - avatar Image? @relation(fields: [avatar_id], references: [id]) + id Int @id @default(autoincrement()) + email String @unique + nickname String @unique + password String? + avatar Image? @relation(fields: [avatar_id], references: [id]) avatar_id Int? - created_at DateTime @default(now()) - updated_at DateTime? @updatedAt - deleted_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime? @updatedAt + deleted_at DateTime? + oauthAccounts OAuthAccount[] @@index([email]) @@index([nickname]) @@ -60,22 +61,26 @@ model User { } model OAuthAccount { - id Int @id @default(autoincrement()) - user_id Int + id Int @id @default(autoincrement()) + user_id Int? // Allow null for accounts that are not linked to a user yet + user User? @relation(fields: [user_id], references: [id], onDelete: SetNull) provider OAuthProvider provider_user_id String provider_username String - provider_avatar_url String + provider_avatar_url String? + provider_metadata Json? created_at DateTime @default(now()) updated_at DateTime? @updatedAt - @@unique([user_id, provider]) - @@unique([provider, provider_user_id]) + @@unique([user_id, provider], name: "user_provider_idx") + @@unique([provider, provider_user_id], name: "provider_user_id_idx") } enum OAuthProvider { GOOGLE GITHUB + DISCORD + NATIVE } diff --git a/database/redis/client.ts b/database/redis/client.ts index 1e882d9..ca04fe5 100644 --- a/database/redis/client.ts +++ b/database/redis/client.ts @@ -2,10 +2,11 @@ import { RedisClient } from "bun"; import databaseConfig from "@/config/database"; -const { redis } = databaseConfig; +const { redis: redisConfig } = databaseConfig; +const { host, port, username, password } = redisConfig; -const client = new RedisClient( - `redis://${redis.username}:${redis.password}@${redis.host}:${redis.port}`, +const redis = new RedisClient( + `redis://${username}:${password}@${host}:${port}`, ); -export default client; +export default redis; diff --git a/modules/auth/exceptions/conflict.exception.ts b/exceptions/conflict.exception.ts similarity index 100% rename from modules/auth/exceptions/conflict.exception.ts rename to exceptions/conflict.exception.ts diff --git a/modules/auth/exceptions/index.exception.ts b/exceptions/index.exception.ts similarity index 100% rename from modules/auth/exceptions/index.exception.ts rename to exceptions/index.exception.ts diff --git a/modules/auth/exceptions/notfound.exception.ts b/exceptions/notfound.exception.ts similarity index 100% rename from modules/auth/exceptions/notfound.exception.ts rename to exceptions/notfound.exception.ts diff --git a/modules/auth/exceptions/unauthorized.exception.ts b/exceptions/unauthorized.exception.ts similarity index 100% rename from modules/auth/exceptions/unauthorized.exception.ts rename to exceptions/unauthorized.exception.ts diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts new file mode 100644 index 0000000..9b4a8cc --- /dev/null +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -0,0 +1,202 @@ +import crypto from "node:crypto"; + +import { OAuthProvider } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { google } from "googleapis"; + +import env from "@/config/env"; +import prisma from "@/database/prisma/client"; +import redis from "@/database/redis/client"; + +import type { Session } from "../models/Session"; +import { GoogleUserSchema } from "../schemas/google-user.schema"; +import { createTokens } from "../serivces/jwt.service"; + +/** + * To use OAuth2 authentication, we need access to a CLIENT_ID, CLIENT_SECRET, AND REDIRECT_URI + * from the client_secret.json file. To get these credentials for your application, visit + * https://console.cloud.google.com/apis/credentials. + */ +const oauth2Client = new google.auth.OAuth2( + { + clientId: env("GOOGLE_CLIENT_ID") as string, + }, + env("GOOGLE_CLIENT_SECRET") as string, + env("GOOGLE_REDIRECT_URI") as string, +); + +export const googleOauth = { + callback: async (req: FastifyRequest, reply: FastifyReply) => { + const { code, state, error } = req.query as { + code: string; + state: string; + error?: string; + }; + + if (error) { + req.log.warn("OAuth error: " + error); + return reply.status(400).send("OAuth error: " + error); + } + + // @ts-expect-error + if (state !== req.state) { + req.log.warn("State mismatch. Possible CSRF attack"); + return reply.status(400).send("State mismatch. Possible CSRF attack"); + } + + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + const oauth2 = google.oauth2({ + auth: oauth2Client, + version: "v2", + }); + + const { data } = await oauth2.userinfo.get(); + const googleUser = GoogleUserSchema.parse(data); + + if (!googleUser.id) { + req.log.warn("Google user ID not found"); + return reply.status(400).send("Google user ID not found"); + } + + const oauthAccount = await prisma.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: googleUser.id, + }, + }, + update: { + provider_username: googleUser.name, + provider_avatar_url: googleUser.picture, + provider_metadata: { + email: googleUser.email, + }, + }, + create: { + provider: OAuthProvider.GOOGLE, + user_id: null, // Will be linked to a user account later + provider_user_id: googleUser.id, + provider_username: googleUser.name, + provider_avatar_url: googleUser.picture, + provider_metadata: { + email: googleUser.email, + }, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: googleUser.email, + }, + update: { + // Do nothing, we don't want to overwrite existing user data + }, + create: { + email: googleUser.email, + nickname: googleUser.name, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + }, + }); + + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .update({ + where: { + id: oauthAccount.id, + }, + data: { + user_id: user.id, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to link OAuth account to user: " + + JSON.stringify( + { + userId: user.id, + oauthAccountId: oauthAccount.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); + + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GOOGLE, + accessToken: tokens.access_token || undefined, + refreshToken: tokens.refresh_token || undefined, + }, + }; + + redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + return reply.send({ + message: req.t("Logged in with Google successfully!"), + tokens: jwtTokens, + data: user, + }); + }, + generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + // Access scopes for two non-Sign-In scopes: Read-only Drive activity and Google Calendar. + const scopes: Array = [ + // "https://www.googleapis.com/auth/drive.metadata.readonly", + // "https://www.googleapis.com/auth/calendar.readonly", + ]; + + // Generate a secure random state value. + const state = crypto.randomBytes(32).toString("hex"); + + // Store state in the session + // @ts-expect-error + req.state = state; + + // Generate a url that asks permissions for the Drive activity and Google Calendar scope + const authorizationUrl = oauth2Client.generateAuthUrl({ + // 'online' (default) or 'offline' (gets refresh_token) + access_type: "offline", + /** Pass in the scopes array defined above. + * Alternatively, if only one scope is needed, you can pass a scope URL as a string */ + scope: scopes, + // Enable incremental authorization. Recommended as a best practice. + include_granted_scopes: true, + // Include the state parameter to reduce the risk of CSRF attacks. + state: state, + }); + + return reply.redirect(authorizationUrl); + }, +}; diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 0a2d16f..35c7b87 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import { OAuthProvider, type User } from "@prisma/client"; import bcrypt from "bcrypt"; import type { FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; @@ -7,15 +8,16 @@ import { ZodError } from "zod"; import jwtConfig from "@/config/jwt"; import prisma from "@/database/prisma/client"; -import redisClient from "@/database/redis/client"; - +import redis from "@/database/redis/client"; import { ConflictError, NotFoundError, UnauthorizedError, -} from "../exceptions/index.exception"; +} from "@/exceptions/index.exception"; + +import type { Session } from "../models/Session"; import { - CredentialSchema, + createCredentialSchema, createSignUpSchema, RefreshTokenSchema, } from "../schemas/index.schema"; @@ -23,21 +25,29 @@ import { createTokens } from "../serivces/jwt.service"; import type { JwtSubject } from "../types/jwt"; export default { - async signUp(req: FastifyRequest, reply: FastifyReply) { + async signup(req: FastifyRequest, reply: FastifyReply) { try { - const Schema = createSignUpSchema(req.t); - - const { password, ...credentials } = Schema.parse(req.body); + const { password, ...credentials } = createSignUpSchema(req.t).parse( + req.body, + ); - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { - email: credentials.email, + OR: [ + { email: credentials.email }, + { nickname: credentials.nickname }, + ], + }, + select: { + id: true, }, }); if (user) { throw new ConflictError( - req.t("Email already in use", { ns: "errors" }), + req.t("Email or Nickename already in use", { + ns: "errors", + }), ); } @@ -54,16 +64,38 @@ export default { }, }); - const tokens = createTokens({ - id: newUser.id, - email: newUser.email, - nickname: newUser.nickname, - }); + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .create({ + data: { + user_id: newUser.id, + + provider: OAuthProvider.NATIVE, + provider_user_id: newUser.id.toString(), + provider_username: newUser.nickname, + // provider_avatar_url: null, + provider_metadata: { + email: newUser.email, + }, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to create OAuth account for user: " + + JSON.stringify( + { + userId: newUser.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); return reply.status(201).send({ message: req.t("User created successfully"), data: newUser, - tokens, }); } catch (e) { if (e instanceof ZodError) { @@ -84,13 +116,16 @@ export default { } }, - async logInViaSSO(req: FastifyRequest, reply: FastifyReply) { + async login(req: FastifyRequest, reply: FastifyReply) { try { - const credentials = CredentialSchema.parse(req.body); + const credentials = createCredentialSchema(req.t).parse(req.body); - const { password, ...user } = await prisma.user.findFirst({ + const user = await prisma.user.findFirst({ where: { - email: credentials.email, + OR: [ + { email: credentials.identifier }, + { nickname: credentials.identifier }, + ], }, select: { id: true, @@ -107,35 +142,51 @@ export default { const authenticated = await bcrypt.compare( credentials.password, - password, + user.password!, ); + delete (user as { password?: string }).password; + if (!authenticated) { throw new UnauthorizedError(req.t("Invalid credentials")); } - const tokens = createTokens({ + const tokens = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { id: user.id, email: user.email, + name: user.nickname, nickname: user.nickname, - }); + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId: tokens.refreshId, + provider: { + type: OAuthProvider.NATIVE, + }, + }; - redisClient.set( + redis.set( `session:${user.id}`, - JSON.stringify({ - ...user, - ipv4: req.ip, - userAgent: req.headers["user-agent"], - version: 1, - }), + JSON.stringify(session), "EX", 1 * 24 * 60 * 60, // session long 1 day max ); - reply.send({ + return reply.status(200).send({ message: req.t("Login successful"), - data: user, tokens, + data: user, }); } catch (e) { if (e instanceof ZodError) { @@ -157,12 +208,16 @@ export default { }); } + if (e instanceof NotFoundError) { + return reply.status(e.statusCode).send({ error: e.message }); + } + console.debug((e as Error).message); return reply.status(500).send({ error: req.t("Server error") }); } }, - logOut(req: FastifyRequest, reply: FastifyReply) { + async logout(req: FastifyRequest, reply: FastifyReply) { try { const bearerToken = req.headers.authorization; if (!bearerToken) { @@ -177,32 +232,28 @@ export default { const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; - const sub = decoded.sub as unknown as { id: string }; + const sub = decoded.sub as unknown as JwtSubject; - redisClient.del(`session:${sub.id}`); + await redis.del(`session:${sub.id}`); reply.status(204).send(); } catch (e) { - console.debug((e as Error).message); - if ( e instanceof jwt.JsonWebTokenError || e instanceof UnauthorizedError ) { - reply + return reply .status(401) .send({ error: req.t("Unauthorized"), details: e.message }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, - refresh(req: FastifyRequest, reply: FastifyReply) { + async refresh(req: FastifyRequest, reply: FastifyReply) { try { - // @todo check if session exist in database - // and validate tokenId with the one in database to prevent reuse of refresh token - const { refreshToken } = RefreshTokenSchema.parse(req.body); const decoded = jwt.verify(refreshToken, jwtConfig.secret, { @@ -213,12 +264,77 @@ export default { throw new UnauthorizedError(req.t("Invalid token type")); } - const tokens = createTokens(decoded.sub); + const subject = decoded.sub as unknown as JwtSubject; + + if (!subject.id) { + throw new UnauthorizedError(req.t("Invalid token subject")); + } + + const refreshId = decoded.jti as string; + + const sessionId = `session:${subject.id}`; + + const rawUserSession = await redis.get(sessionId); + + if (!rawUserSession) { + throw new NotFoundError(req.t("Session not found")); + } + + const userSession = JSON.parse(rawUserSession) as { + refreshId: string; + version: number; + }; + + if (userSession.refreshId !== refreshId) { + throw new UnauthorizedError(req.t("Invalid refresh token")); + } + + const newVersion = userSession.version + 1; + + const tokens = createTokens({ ...subject }, newVersion); + + const user = await prisma.user.findUnique({ + where: { + id: Number(subject.id), + }, + select: { + id: true, + email: true, + nickname: true, + }, + }); + + if (!user) { + throw new NotFoundError(req.t("User not found")); + } + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: newVersion, + refreshId: tokens.refreshId, + provider: { + type: OAuthProvider.NATIVE, + }, + }; + + await redis.set( + sessionId, + JSON.stringify(session), + "EX", + 1 * 24 * 60 * 60, // session long 1 day max + ); - reply.send({ + return reply.status(200).send({ message: req.t("Token refreshed successfully"), tokens, - data: decoded.sub, + data: user, }); } catch (e) { if (e instanceof ZodError) { @@ -235,6 +351,14 @@ export default { }); } + if (e instanceof NotFoundError) { + return reply.status(404).send({ error: e.message }); + } + + if (e instanceof UnauthorizedError) { + return reply.status(401).send({ error: e.message }); + } + console.debug((e as Error).message); return reply.status(500).send({ error: req.t("Server error") }); } @@ -257,22 +381,20 @@ export default { }) as jwt.JwtPayload; const { id: userId } = decoded.sub as unknown as JwtSubject; - if (!redisClient.exists(`session:${userId}`)) { + if (!redis.exists(`session:${userId}`)) { throw new NotFoundError(req.t("Session not found")); } - const rawUserSession = await redisClient.get(`session:${userId}`); - const userSession = JSON.parse(rawUserSession as string); - - redisClient.set( - `session:${userId}`, - JSON.stringify({ - ...userSession, - version: userSession.version + 1, - }), - "KEEPTTL", - ); + const rawSession = await redis.get(`session:${userId}`); + const session = JSON.parse(rawSession as string) as Session; + + const updatedSession: Session = { + ...session, + v: session.v + 1, + }; + + redis.set(`session:${userId}`, JSON.stringify(updatedSession), "KEEPTTL"); - reply.send({ message: req.t("Session revoked") }); + return reply.status(204).send(); } catch (e) { if (e instanceof ZodError) { return reply.status(400).send({ diff --git a/modules/auth/models/Auth.d.ts b/modules/auth/models/Auth.d.ts deleted file mode 100644 index e69de29..0000000 diff --git a/modules/auth/models/Session.d.ts b/modules/auth/models/Session.d.ts new file mode 100644 index 0000000..a6b00bc --- /dev/null +++ b/modules/auth/models/Session.d.ts @@ -0,0 +1,14 @@ +import type { User } from "@/modules/users/models/User"; +import type { OAuthProvider } from "@prisma/client"; + +export interface Session extends User { + ipv4: `${number}.${number}.${number}.${number}`; + refreshId: string; + userAgent: string; + v: number; + provider: { + type: OAuthProvider; + refreshToken?: string; + accessToken?: string; + }; +} diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index aba98b8..bd4ad1e 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -2,13 +2,25 @@ import type { FastifyInstance } from "fastify"; import AuthController from "@/modules/auth/controllers/index.controller"; +import { googleOauth } from "../controllers/google-oauth.controller"; + export default async function authRouter(f: FastifyInstance) { - f.post("/signup", AuthController.signUp); - f.post("/login/sso", AuthController.logInViaSSO); - // f.post("/login/google", AuthController.login); - // f.post("/login/github", AuthController.login); - // f.post("/login/discord", AuthController.login); + f.post("/signup", AuthController.signup); + + f.post("/login", AuthController.login); + f.post("/refresh", AuthController.refresh); + f.delete("/revoke", AuthController.revoke); - f.get("/logout", AuthController.logOut); + + f.delete("/logout", AuthController.logout); + + f.get("/url/google", googleOauth.generateUrl); + f.get("/callback/google", googleOauth.callback); + + // f.get("/url/github", githubOauth.generateUrl); + // f.get("/callback/github", githubOauth.callback); + + // f.get("/url/discord", discordOauth.generateUrl); + // f.get("/callback/discord", discordOauth.callback); } diff --git a/modules/auth/schemas/credential.schema.ts b/modules/auth/schemas/credential.schema.ts index 58cf749..8ced8b4 100644 --- a/modules/auth/schemas/credential.schema.ts +++ b/modules/auth/schemas/credential.schema.ts @@ -1,6 +1,17 @@ import { z } from "zod"; -export const CredentialSchema = z.object({ - email: z.email(), - password: z.string().min(8), -}); +export const createCredentialSchema = (t: i18next.TFunction) => { + return z.object({ + identifier: z.email().or( + z + .string() + // Must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed. + // Valid: "john_doe", "john.doe", "john-doe", "john123" + // Invalid: "_john", "john_", "john..doe", "john__doe", "john--doe", "john.-doe", "john-.doe" + .regex(/^[a-zA-Z0-9]([._-](?![._-])|[a-zA-Z0-9]){1,14}[a-zA-Z0-9]$/, { + error: t("nickname_rules", { ns: "zod" }), + }), + ), + password: z.string().min(8), + }); +}; diff --git a/modules/auth/schemas/google-user.schema.ts b/modules/auth/schemas/google-user.schema.ts new file mode 100644 index 0000000..4432045 --- /dev/null +++ b/modules/auth/schemas/google-user.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const GoogleUserSchema = z.object({ + id: z.string(), + email: z.email(), + name: z.string(), + picture: z.url().optional(), +}); diff --git a/modules/auth/schemas/index.schema.ts b/modules/auth/schemas/index.schema.ts index 03e6df2..5d4740d 100644 --- a/modules/auth/schemas/index.schema.ts +++ b/modules/auth/schemas/index.schema.ts @@ -1,4 +1,5 @@ export * from "./credential.schema"; +export * from "./google-user.schema"; export * from "./refresh-token.schema"; export * from "./signin.schema"; export * from "./signup.schema"; diff --git a/modules/auth/serivces/google.service.ts b/modules/auth/serivces/google.service.ts new file mode 100644 index 0000000..8b88f0a --- /dev/null +++ b/modules/auth/serivces/google.service.ts @@ -0,0 +1 @@ +export namespace GoogleOauth {} diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index 58351e7..cf02f53 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -4,15 +4,18 @@ import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; -export const createTokens = (sub: unknown) => { +export const createTokens = (sub: unknown, version: number = 1) => { const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour expiration const iat = Math.floor(Date.now() / 1000); + const jti = crypto.randomUUID(); const accessToken = jwt.sign( { exp, iat, sub, + v: version, + type: "access", }, jwtConfig.secret, { algorithm: jwtConfig.algorithm }, @@ -24,7 +27,7 @@ export const createTokens = (sub: unknown) => { iat, sub, type: "refresh", - tokenId: crypto.randomUUID(), + jti, }, jwtConfig.secret, { algorithm: jwtConfig.algorithm }, @@ -35,5 +38,6 @@ export const createTokens = (sub: unknown) => { refreshToken, expiresIn: 60 * 60, // 1 hour in seconds issuedAt: iat, + refreshId: jti, }; }; diff --git a/modules/auth/types/jwt.d.ts b/modules/auth/types/jwt.d.ts index 906c090..8bd0f6f 100644 --- a/modules/auth/types/jwt.d.ts +++ b/modules/auth/types/jwt.d.ts @@ -1,15 +1,15 @@ -export interface JwtPayload { - sub: JwtSubject; - email: string; - iat: number; - exp: number; -} - export interface JwtSubject { id: string; email: string; - username: string; + nickname: string; name: string; - avatarUrl: string; - roles: string[]; + version: number; +} + +export interface JwtPayload { + sub: JwtSubject; + iat: number; + exp: number; + jti?: string; + type: "access" | "refresh"; } diff --git a/modules/users/models/User.d.ts b/modules/users/models/User.d.ts index e69de29..3119ada 100644 --- a/modules/users/models/User.d.ts +++ b/modules/users/models/User.d.ts @@ -0,0 +1,6 @@ +export interface User { + id: number; + email: string; + nickname: string; + name: string; +} diff --git a/package.json b/package.json index 08d7f82..92cd1fb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", + "googleapis": "^171.4.0", "i18next": "^26.0.3", "i18next-fs-backend": "^2.6.3", "i18next-http-middleware": "^3.9.2", diff --git a/plugins/jwt.ts b/plugins/jwt.ts index dd2459c..93eff85 100644 --- a/plugins/jwt.ts +++ b/plugins/jwt.ts @@ -2,7 +2,9 @@ import fp from "fastify-plugin"; import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; -import redisClient from "@/database/redis/client"; +import redis from "@/database/redis/client"; +import type { Session } from "@/modules/auth/models/Session"; +import type { JwtSubject } from "@/modules/auth/types/jwt"; export default fp(async (f) => { f.decorateRequest("user", null); @@ -32,24 +34,26 @@ export default fp(async (f) => { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; - const sub = decoded.sub as unknown as { - id: number; - email: string; - nickname: string; - }; + const subject = decoded.sub as unknown as JwtSubject; - const session = await redisClient.get(`session:${sub.id}`); + const rawSession = await redis.get(`session:${subject.id}`); - if (!session) { + if (!rawSession) { throw new jwt.JsonWebTokenError(req.t("Session expired or invalid")); } + const session = JSON.parse(rawSession) as Session; + + if (decoded.v !== session.v) { + throw new jwt.JsonWebTokenError(req.t("Session was invalidated")); + } + // @ts-expect-error - req.user = JSON.parse(session); + req.user = session; } catch (e) { if (e instanceof jwt.JsonWebTokenError) { return reply - .status(401) + .status(403) .send({ error: req.t("Unauthorized"), details: e.message }); } From 6b9f84a40298e2b2db3e7a279d87cee1ec3bc652 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 16:01:53 +0000 Subject: [PATCH 04/15] fix(jwt.service): read token lifetimes from config; add +10s grace on refresh token Agent-Logs-Url: https://github.com/dojoh-dev/api/sessions/e51ba94d-b8f5-4c51-8fb6-1f6c2da7b64f Co-authored-by: itssimmons <62354548+itssimmons@users.noreply.github.com> --- config/jwt.ts | 4 ++-- modules/auth/serivces/jwt.service.ts | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/config/jwt.ts b/config/jwt.ts index bfad069..b4cfa46 100644 --- a/config/jwt.ts +++ b/config/jwt.ts @@ -6,10 +6,10 @@ export default { secret: env("JWT_SECRET", "default_secret"), access: { - expiresIn: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour in seconds + expiresIn: 60 * 60, // 1 hour in seconds }, refresh: { - expiresIn: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days in seconds + expiresIn: 60 * 60, // 1 hour in seconds }, } satisfies JwtConfig; diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index cf02f53..8d7ecd4 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -4,15 +4,18 @@ import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; +const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 10; + export const createTokens = (sub: unknown, version: number = 1) => { - const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour expiration - const iat = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000); + const accessExp = now + jwtConfig.access.expiresIn; + const refreshExp = now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; const jti = crypto.randomUUID(); const accessToken = jwt.sign( { - exp, - iat, + exp: accessExp, + iat: now, sub, v: version, type: "access", @@ -23,8 +26,8 @@ export const createTokens = (sub: unknown, version: number = 1) => { const refreshToken = jwt.sign( { - exp, - iat, + exp: refreshExp, + iat: now, sub, type: "refresh", jti, @@ -36,8 +39,8 @@ export const createTokens = (sub: unknown, version: number = 1) => { return { accessToken, refreshToken, - expiresIn: 60 * 60, // 1 hour in seconds - issuedAt: iat, + expiresIn: jwtConfig.access.expiresIn, + issuedAt: now, refreshId: jti, }; }; From 984d71495e25289bb1d037605dd6baf1fa1008b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 16:08:19 +0000 Subject: [PATCH 05/15] fix(auth): return 401 instead of 500 when OAuth user has no password on native login Agent-Logs-Url: https://github.com/dojoh-dev/api/sessions/f56905a4-f168-4cf2-9242-dd5ea3a6ad28 Co-authored-by: itssimmons <62354548+itssimmons@users.noreply.github.com> --- modules/auth/controllers/index.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 35c7b87..bd0e1b2 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -136,13 +136,13 @@ export default { }, }); - if (!user) { - throw new NotFoundError(req.t("User not found")); + if (!user || !user.password) { + throw new UnauthorizedError(req.t("Invalid credentials")); } const authenticated = await bcrypt.compare( credentials.password, - user.password!, + user.password, ); delete (user as { password?: string }).password; From 52cdf2558741960c0fb20d5350e19d7e9daca0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 16:51:18 -0300 Subject: [PATCH 06/15] feat(auth): github & discord oauth2 integration --- compose.yml | 3 +- .../controllers/discord-oauth.controller.ts | 237 ++++++++++++++++++ .../controllers/github-oauth.controller.ts | 235 +++++++++++++++++ .../controllers/google-oauth.controller.ts | 14 +- modules/auth/controllers/index.controller.ts | 15 +- modules/auth/routes/index.router.ts | 19 +- modules/auth/schemas/discord-user.schema.ts | 10 + modules/auth/schemas/github-user.schema.ts | 9 + modules/users/controllers/.gitkeep | 0 modules/users/controllers/index.controller.ts | 20 -- plugins/zod.ts | 2 +- shared/utils/crypto.ts | 24 ++ 12 files changed, 544 insertions(+), 44 deletions(-) create mode 100644 modules/auth/controllers/discord-oauth.controller.ts create mode 100644 modules/auth/controllers/github-oauth.controller.ts create mode 100644 modules/auth/schemas/discord-user.schema.ts create mode 100644 modules/auth/schemas/github-user.schema.ts create mode 100644 modules/users/controllers/.gitkeep delete mode 100644 modules/users/controllers/index.controller.ts create mode 100644 shared/utils/crypto.ts diff --git a/compose.yml b/compose.yml index d68432e..20afbb3 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,7 @@ services: context: . ports: - "8080:8080" + env_file: .env environment: - NODE_ENV=development networks: @@ -47,7 +48,7 @@ services: environment: - REDIS_PASSWORD=my_secret_password command: > - sh -c "redis-server --requirepass ${REDIS_PASSWORD}" + sh -c "redis-server --requirepass $$REDIS_PASSWORD" networks: - dojoh-net diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts new file mode 100644 index 0000000..2619b0c --- /dev/null +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -0,0 +1,237 @@ +import crypto from "node:crypto"; + +import { OAuthProvider } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import env from "@/config/env"; +import prisma from "@/database/prisma/client"; +import redis from "@/database/redis/client"; + +import type { Session } from "../models/Session"; +import { DiscordUserSchema } from "../schemas/discord-user.schema"; +import { createTokens } from "../serivces/jwt.service"; + +const DiscordOauthController = { + callback: async (req: FastifyRequest, reply: FastifyReply) => { + const { code, state, error } = req.query as { + code: string; + state: string; + error?: string; + }; + + if (error) { + req.log.warn(`OAuth error: ${error}`); + return reply.status(400).send(`OAuth error: ${error}`); + } + + // @ts-expect-error + if (state !== req.state) { + req.log.warn("State mismatch. Possible CSRF attack"); + return reply.status(400).send("State mismatch. Possible CSRF attack"); + } + + const basicToken = Buffer.from( + `${env("DISCORD_CLIENT_ID")}:${env("DISCORD_CLIENT_SECRET")}`, + ).toString("base64"); + const urlencoded = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: env("DISCORD_REDIRECT_URI") as string, + }); + const tokenResponse = await fetch("https://discord.com/api/v10", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicToken}`, + }, + body: urlencoded.toString(), + }); + + if (!tokenResponse.ok) { + const content = await tokenResponse.text(); + req.log.warn(`Failed to obtain access token from Discord: ${content}`); + return reply + .status(400) + .send("Failed to obtain access token from Discord"); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token: string; + scope: "identify"; + }; + + if (!tokens.access_token) { + req.log.warn("Failed to obtain access token from Discord"); + return reply + .status(400) + .send("Failed to obtain access token from Discord"); + } + + const userResponse = await fetch("https://discord.com/api/v10/users/@me", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (!userResponse.ok) { + const content = await userResponse.text(); + req.log.warn(`Failed to obtain user info from Discord: ${content}`); + return reply.status(400).send("Failed to obtain user info from Discord"); + } + + const userData = await userResponse.json(); + const discordUser = DiscordUserSchema.parse(userData); + + if (!discordUser.id) { + req.log.warn("Google user ID not found"); + return reply.status(400).send("Google user ID not found"); + } + + const oauthAccount = await prisma.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: discordUser.id, + }, + }, + update: { + provider_username: discordUser.username, + provider_avatar_url: discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null, + provider_metadata: { + email: discordUser.email, + lng: discordUser.locale, + discriminator: discordUser.discriminator, + }, + }, + create: { + provider: OAuthProvider.DISCORD, + user_id: null, // Will be linked to a user account later + provider_user_id: discordUser.id, + provider_username: discordUser.username, + provider_avatar_url: discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null, + provider_metadata: { + email: discordUser.email, + locale: discordUser.locale, + discriminator: discordUser.discriminator, + }, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: discordUser.email, + }, + update: { + // Do nothing, we don't want to overwrite existing user data + }, + create: { + email: discordUser.email || "", + nickname: discordUser.username, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + }, + }); + + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .update({ + where: { + id: oauthAccount.id, + }, + data: { + user_id: user.id, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to link OAuth account to user: " + + JSON.stringify( + { + userId: user.id, + oauthAccountId: oauthAccount.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); + + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GOOGLE, + accessToken: tokens.access_token || undefined, + refreshToken: tokens.refresh_token || undefined, + }, + }; + + redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + return reply.send({ + message: req.t("Logged in with Google successfully!"), + tokens: jwtTokens, + data: user, + }); + }, + generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + const state = crypto.randomBytes(32).toString("hex"); + + const params = new URLSearchParams({ + response_type: "code", + client_id: env("DISCORD_CLIENT_ID") as string, + redirect_uri: env("DISCORD_REDIRECT_URI") as string, + scope: ["identify", "email"].join("%20"), + prompt: "consent", // Always ask user to select account + integration_type: "1", // USER_INSTALL + }); + + // @ts-expect-error + req.state = state; + + const url = new URL( + `/oauth2/authorize?${params.toString()}`, + "https://discord.com", + ); + + const authorizationUrl = url.toString(); + + return reply.redirect(authorizationUrl); + }, +} as const; + +export default DiscordOauthController; diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts new file mode 100644 index 0000000..1d23dfa --- /dev/null +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -0,0 +1,235 @@ +import crypto from "node:crypto"; + +import { OAuthProvider } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import env from "@/config/env"; +import prisma from "@/database/prisma/client"; +import redis from "@/database/redis/client"; +import { + generateCodeChallenge, + generateCodeVerifier, +} from "@/shared/utils/crypto"; + +import type { Session } from "../models/Session"; +import { GithubUserSchema } from "../schemas/github-user.schema"; +import { createTokens } from "../serivces/jwt.service"; + +const GithubOauthController = { + callback: async (req: FastifyRequest, reply: FastifyReply) => { + const { code, state } = req.query as { + code: string; + state: string; + }; + + // if (error) { + // req.log.warn("OAuth error: " + error); + // return reply.status(400).send("OAuth error: " + error); + // } + + // @ts-expect-error + if (state !== req.state) { + req.log.warn("State mismatch. Possible CSRF attack"); + return reply.status(400).send("State mismatch. Possible CSRF attack"); + } + + const tokenResponse = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: env("GITHUB_CLIENT_ID"), + client_secret: env("GITHUB_CLIENT_SECRET"), + code, + redirect_uri: env("GITHUB_REDIRECT_URI"), + }), + }, + ); + + if (!tokenResponse.ok) { + const content = await tokenResponse.text(); + req.log.warn(`Failed to obtain access token from GitHub: ${content}`); + return reply + .status(400) + .send("Failed to obtain access token from GitHub"); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + scope: string; + token_type: string; + }; + + if (!tokens.access_token) { + req.log.warn("Failed to obtain access token from GitHub"); + return reply + .status(400) + .send("Failed to obtain access token from GitHub"); + } + + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + }, + }); + + if (!userResponse.ok) { + req.log.warn( + `Failed to fetch user info from GitHub: ${await userResponse.text()}`, + ); + return reply.status(400).send("Failed to fetch user info from GitHub"); + } + + const userData = await userResponse.json(); + const githubUser = GithubUserSchema.parse(userData); + + if (!githubUser.id) { + req.log.warn("Google user ID not found"); + return reply.status(400).send("Google user ID not found"); + } + + const oauthAccount = await prisma.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: githubUser.id, + }, + }, + update: { + provider_username: githubUser.name, + provider_avatar_url: githubUser.avatar_url || null, + provider_metadata: { + email: githubUser.email, + name: githubUser.name, + }, + }, + create: { + provider: OAuthProvider.GOOGLE, + user_id: null, // Will be linked to a user account later + provider_user_id: githubUser.id, + provider_username: githubUser.login, + provider_avatar_url: githubUser.avatar_url, + provider_metadata: { + email: githubUser.email, + name: githubUser.name, + }, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: githubUser.email, + }, + update: { + // Do nothing, we don't want to overwrite existing user data + }, + create: { + email: githubUser.email || "", + nickname: githubUser.login, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + }, + }); + + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .update({ + where: { + id: oauthAccount.id, + }, + data: { + user_id: user.id, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to link OAuth account to user: " + + JSON.stringify( + { + userId: user.id, + oauthAccountId: oauthAccount.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); + + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GITHUB, + accessToken: tokens.access_token || undefined, + }, + }; + + await redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + return reply.send({ + message: req.t("Logged in with Github successfully!"), + tokens: jwtTokens, + data: user, + }); + }, + generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = crypto.randomBytes(32).toString("hex"); + + const params = new URLSearchParams({ + client_id: env("GITHUB_CLIENT_ID") as string, + redirect_uri: env("GITHUB_REDIRECT_URI") as string, + scope: ["user:email", "read:user"].join(" "), + state, + code_challenge_method: "S256", + code_challenge: challenge, + }); + + // @ts-expect-error + req.state = state; + + const url = new URL( + `/login/oauth/authorize?${params.toString()}`, + "https://github.com", + ); + + const authorizationUrl = url.toString(); + + return reply.redirect(authorizationUrl); + }, +}; + +export default GithubOauthController; diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 9b4a8cc..6372672 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -25,7 +25,7 @@ const oauth2Client = new google.auth.OAuth2( env("GOOGLE_REDIRECT_URI") as string, ); -export const googleOauth = { +const GoogleOauthController = { callback: async (req: FastifyRequest, reply: FastifyReply) => { const { code, state, error } = req.query as { code: string; @@ -34,8 +34,8 @@ export const googleOauth = { }; if (error) { - req.log.warn("OAuth error: " + error); - return reply.status(400).send("OAuth error: " + error); + req.log.warn(`OAuth error: ${error}`); + return reply.status(400).send(`OAuth error: ${error}`); } // @ts-expect-error @@ -171,11 +171,7 @@ export const googleOauth = { }); }, generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { - // Access scopes for two non-Sign-In scopes: Read-only Drive activity and Google Calendar. - const scopes: Array = [ - // "https://www.googleapis.com/auth/drive.metadata.readonly", - // "https://www.googleapis.com/auth/calendar.readonly", - ]; + const scopes: Array = ["openid", "profile", "email"]; // Generate a secure random state value. const state = crypto.randomBytes(32).toString("hex"); @@ -200,3 +196,5 @@ export const googleOauth = { return reply.redirect(authorizationUrl); }, }; + +export default GoogleOauthController; diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index bd0e1b2..a731edc 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -1,6 +1,4 @@ -import crypto from "node:crypto"; - -import { OAuthProvider, type User } from "@prisma/client"; +import { OAuthProvider } from "@prisma/client"; import bcrypt from "bcrypt"; import type { FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; @@ -45,7 +43,7 @@ export default { if (user) { throw new ConflictError( - req.t("Email or Nickename already in use", { + req.t("Email or Nickname already in use", { ns: "errors", }), ); @@ -140,6 +138,10 @@ export default { throw new UnauthorizedError(req.t("Invalid credentials")); } + if (!user.password) { + throw new UnauthorizedError(req.t("Invalid credentials")); + } + const authenticated = await bcrypt.compare( credentials.password, user.password, @@ -376,12 +378,15 @@ export default { throw new UnauthorizedError(req.t("Unauthorized")); } + // TODO: revoke thrid party sessions if the user logged in with OAuth provider (google, github, discord, etc) + const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; const { id: userId } = decoded.sub as unknown as JwtSubject; - if (!redis.exists(`session:${userId}`)) { + const sessionExists = await redis.exists(`session:${userId}`); + if (!sessionExists) { throw new NotFoundError(req.t("Session not found")); } const rawSession = await redis.get(`session:${userId}`); diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index bd4ad1e..df45c6b 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -1,8 +1,9 @@ import type { FastifyInstance } from "fastify"; -import AuthController from "@/modules/auth/controllers/index.controller"; - -import { googleOauth } from "../controllers/google-oauth.controller"; +import DiscordOauthController from "../controllers/discord-oauth.controller"; +import GithubOauthController from "../controllers/github-oauth.controller"; +import GoogleOauthController from "../controllers/google-oauth.controller"; +import AuthController from "../controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { f.post("/signup", AuthController.signup); @@ -15,12 +16,12 @@ export default async function authRouter(f: FastifyInstance) { f.delete("/logout", AuthController.logout); - f.get("/url/google", googleOauth.generateUrl); - f.get("/callback/google", googleOauth.callback); + f.get("/url/google", GoogleOauthController.generateUrl); + f.get("/callback/google", GoogleOauthController.callback); - // f.get("/url/github", githubOauth.generateUrl); - // f.get("/callback/github", githubOauth.callback); + f.get("/url/github", GithubOauthController.generateUrl); + f.get("/callback/github", GithubOauthController.callback); - // f.get("/url/discord", discordOauth.generateUrl); - // f.get("/callback/discord", discordOauth.callback); + f.get("/url/discord", DiscordOauthController.generateUrl); + f.get("/callback/discord", DiscordOauthController.callback); } diff --git a/modules/auth/schemas/discord-user.schema.ts b/modules/auth/schemas/discord-user.schema.ts new file mode 100644 index 0000000..91fc464 --- /dev/null +++ b/modules/auth/schemas/discord-user.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const DiscordUserSchema = z.object({ + id: z.string(), + username: z.string(), + avatar: z.string().optional(), + email: z.email().optional(), + discriminator: z.string().optional(), + locale: z.string().optional(), +}); diff --git a/modules/auth/schemas/github-user.schema.ts b/modules/auth/schemas/github-user.schema.ts new file mode 100644 index 0000000..08dac7b --- /dev/null +++ b/modules/auth/schemas/github-user.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const GithubUserSchema = z.object({ + id: z.string(), + login: z.string(), + avatar_url: z.url().optional(), + email: z.email().optional(), + name: z.string().optional(), +}); diff --git a/modules/users/controllers/.gitkeep b/modules/users/controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/users/controllers/index.controller.ts b/modules/users/controllers/index.controller.ts deleted file mode 100644 index 210ba6b..0000000 --- a/modules/users/controllers/index.controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; - -import prisma from "@/database/prisma/client"; - -// export default { -// async uploadAvatar(req: FastifyRequest, reply: FastifyReply) { -// const userId = req.user.id; - -// if (!req.file) { -// return reply.status(400).send({ error: req.t("No file uploaded") }); -// } - -// const avatarUrl = `/uploads/${req.file.filename}`; - -// try { -// await prisma.image.create({ - -// }); -// } -// } as const; diff --git a/plugins/zod.ts b/plugins/zod.ts index 83faf4d..de5ad96 100644 --- a/plugins/zod.ts +++ b/plugins/zod.ts @@ -2,6 +2,6 @@ import fp from "fastify-plugin"; export default fp(async (f) => { f.addHook("preHandler", async (req) => { - const lng = req.query; + const _lng = req.query; }); }); diff --git a/shared/utils/crypto.ts b/shared/utils/crypto.ts new file mode 100644 index 0000000..3f4f51c --- /dev/null +++ b/shared/utils/crypto.ts @@ -0,0 +1,24 @@ +export const generateCodeVerifier = (length = 128) => { + const charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let result = ""; + const randomValues = crypto.getRandomValues(new Uint8Array(length)); + + for (let i = 0; i < length; i++) { + result += charset[(randomValues[i] as number) % charset.length]; + } + + return result; +}; + +export const generateCodeChallenge = async (verifier: string) => { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + + const digest = await crypto.subtle.digest("SHA-256", data); + + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +}; From 0ef509b4244c60e33c12e861958660fc641ca72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 21:19:17 -0300 Subject: [PATCH 07/15] fix: biome check --- modules/auth/controllers/index.controller.ts | 2 +- modules/auth/serivces/jwt.service.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index a731edc..6f834df 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -134,7 +134,7 @@ export default { }, }); - if (!user || !user.password) { + if (!user?.password) { throw new UnauthorizedError(req.t("Invalid credentials")); } diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index 8d7ecd4..c251344 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -9,7 +9,8 @@ const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 10; export const createTokens = (sub: unknown, version: number = 1) => { const now = Math.floor(Date.now() / 1000); const accessExp = now + jwtConfig.access.expiresIn; - const refreshExp = now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; + const refreshExp = + now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; const jti = crypto.randomUUID(); const accessToken = jwt.sign( From 1a114b01fd072ff5e48cf356ba694fd3eaec0622 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 01:21:49 +0000 Subject: [PATCH 08/15] fix(github-oauth): fall back to nickname when GitHub email is absent Agent-Logs-Url: https://github.com/dojoh-dev/api/sessions/2b746871-9486-4932-89f8-036a9433bb65 Co-authored-by: itssimmons <62354548+itssimmons@users.noreply.github.com> --- modules/auth/controllers/github-oauth.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index 1d23dfa..4ddbad8 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -122,14 +122,14 @@ const GithubOauthController = { }); const user = await prisma.user.upsert({ - where: { - email: githubUser.email, - }, + where: githubUser.email + ? { email: githubUser.email } + : { nickname: githubUser.login }, update: { // Do nothing, we don't want to overwrite existing user data }, create: { - email: githubUser.email || "", + email: githubUser.email ?? "", nickname: githubUser.login, }, select: { From bff4bc0cd10457528fb6c450c4aff750dbb7dee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 22:27:00 -0300 Subject: [PATCH 09/15] fix: github comments --- .env.example | 26 +++++++-- bun.lock | 3 ++ config/jwt.ts | 2 +- index.ts | 49 ++--------------- .../controllers/discord-oauth.controller.ts | 53 ++++++++++--------- .../controllers/github-oauth.controller.ts | 42 ++++++++++----- .../controllers/google-oauth.controller.ts | 21 +++++--- modules/auth/controllers/index.controller.ts | 15 +++--- modules/auth/routes/index.router.ts | 20 ++----- modules/auth/routes/oauth.router.ts | 16 ++++++ modules/auth/serivces/jwt.service.ts | 5 +- package.json | 1 + plugins/i18n.ts | 2 +- plugins/jwt.ts | 29 ++++------ plugins/zod.ts | 7 --- 15 files changed, 143 insertions(+), 148 deletions(-) create mode 100644 modules/auth/routes/oauth.router.ts delete mode 100644 plugins/zod.ts diff --git a/.env.example b/.env.example index 9bb2dca..dd872f4 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,28 @@ -NODE_ENV= +NODE_ENV=development -POSTGRES_USERNAME= +POSTGRES_USERNAME=postgres POSTGRES_DATABASE= POSTGRES_PASSWORD= POSTGRES_HOST= -POSTGRES_PORT= +POSTGRES_PORT=5432 + +DATABASE_URL=postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE} REDIS_HOST= -REDIS_PORT= -REDIS_USERNAME= +REDIS_PORT=6379 +REDIS_USERNAME=default REDIS_PASSWORD= + +COOKIE_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI=https://example.com/auth/google/callback + +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI=https://example.com/auth/github/callback diff --git a/bun.lock b/bun.lock index b739060..8890a4c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "dojoh-dev", "dependencies": { + "@fastify/cookie": "^11.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcrypt": "^6.0.0", @@ -98,6 +99,8 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + "@fastify/cookie": ["@fastify/cookie@11.0.2", "", { "dependencies": { "cookie": "^1.0.0", "fastify-plugin": "^5.0.0" } }, "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], diff --git a/config/jwt.ts b/config/jwt.ts index b4cfa46..a92a548 100644 --- a/config/jwt.ts +++ b/config/jwt.ts @@ -10,6 +10,6 @@ export default { }, refresh: { - expiresIn: 60 * 60, // 1 hour in seconds + expiresIn: 60 * 60 * 24, // 1 day in seconds }, } satisfies JwtConfig; diff --git a/index.ts b/index.ts index 10d797c..73bf513 100644 --- a/index.ts +++ b/index.ts @@ -1,62 +1,23 @@ +import fastifyCookie from "@fastify/cookie"; import Fastify from "fastify"; import env from "./config/env"; import i18nPlugin from "./plugins/i18n"; -import jwtPlugin from "./plugins/jwt"; const f = Fastify({ logger: true, }); -f.register(i18nPlugin); -f.register(jwtPlugin); - -f.get("/", async () => { - return { message: "Hello World!" }; +f.register(fastifyCookie, { + secret: env("COOKIE_SECRET", undefined), }); +f.register(i18nPlugin); + f.get("/health", async () => { return { status: "ok", timestamp: new Date().toISOString() }; }); -f.post("/echo", async (request) => { - console.debug("[BODY]: ", request.body); - console.debug("[QUERY]: ", request.query); - console.debug("[PARAMS]: ", request.params); - console.debug("[HEADERS]: ", request.headers); - // console.debug("[RAW]: ", request.raw); - // console.debug("[SERVER]: ", request.server); - console.debug("[ID]: ", request.id); - console.debug("[IP]: ", request.ip); - console.debug("[IPS]: ", request.ips); - console.debug("[HOST]: ", request.host); - console.debug("[HOSTNAME]: ", request.hostname); - console.debug("[PORT]: ", request.port); - console.debug("[PROTOCOL]: ", request.protocol); - console.debug("[URL]: ", request.url); - console.debug("[ROUTE_METHOD]: ", request.routeOptions.method); - console.debug( - "[ROUTE_BODY_LIMIT]: ", - request.routeOptions.bodyLimit - ? `${(request.routeOptions.bodyLimit / (1024 * 1024)).toFixed(2)} MB` - : "N/A", - ); - console.debug("[ROUTE_URL]: ", request.routeOptions.url); - console.debug( - "[ROUTE_ATTACH_VALIDATION]: ", - request.routeOptions.attachValidation, - ); - console.debug("[ROUTE_LOG_LEVEL]: ", request.routeOptions.logLevel); - console.debug("[ROUTE_VERSION]: ", request.routeOptions.version); - console.debug("[ROUTE_EXPOSE_HEAD]: ", request.routeOptions.exposeHeadRoute); - console.debug( - "[ROUTE_PREFIX_TRAILING_SLASH]: ", - request.routeOptions.prefixTrailingSlash, - ); - - return { received: request.body }; -}); - // Register route modules f.register(async (f) => { const { default: version_1 } = await import("./routes/v1"); diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 2619b0c..8a81681 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -13,19 +13,14 @@ import { createTokens } from "../serivces/jwt.service"; const DiscordOauthController = { callback: async (req: FastifyRequest, reply: FastifyReply) => { - const { code, state, error } = req.query as { + const { code, state } = req.query as { code: string; state: string; - error?: string; }; - if (error) { - req.log.warn(`OAuth error: ${error}`); - return reply.status(400).send(`OAuth error: ${error}`); - } + const storedState = req.cookies["oauth_state"]; - // @ts-expect-error - if (state !== req.state) { + if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); return reply.status(400).send("State mismatch. Possible CSRF attack"); } @@ -38,14 +33,17 @@ const DiscordOauthController = { code, redirect_uri: env("DISCORD_REDIRECT_URI") as string, }); - const tokenResponse = await fetch("https://discord.com/api/v10", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${basicToken}`, + const tokenResponse = await fetch( + "https://discord.com/api/v10/oauth2/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicToken}`, + }, + body: urlencoded.toString(), }, - body: urlencoded.toString(), - }); + ); if (!tokenResponse.ok) { const content = await tokenResponse.text(); @@ -86,14 +84,14 @@ const DiscordOauthController = { const discordUser = DiscordUserSchema.parse(userData); if (!discordUser.id) { - req.log.warn("Google user ID not found"); - return reply.status(400).send("Google user ID not found"); + req.log.warn("Discord user ID not found"); + return reply.status(400).send("Discord user ID not found"); } const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { - provider: OAuthProvider.GOOGLE, + provider: OAuthProvider.DISCORD, provider_user_id: discordUser.id, }, }, @@ -189,13 +187,13 @@ const DiscordOauthController = { v: 1, refreshId, provider: { - type: OAuthProvider.GOOGLE, + type: OAuthProvider.DISCORD, accessToken: tokens.access_token || undefined, refreshToken: tokens.refresh_token || undefined, }, }; - redis.set( + await redis.set( `session:${user.id}`, JSON.stringify(session), "EX", @@ -203,25 +201,30 @@ const DiscordOauthController = { ); return reply.send({ - message: req.t("Logged in with Google successfully!"), + message: req.t("Logged in with Discord successfully!"), tokens: jwtTokens, data: user, }); }, - generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + generateUrl: async (_: FastifyRequest, reply: FastifyReply) => { const state = crypto.randomBytes(32).toString("hex"); const params = new URLSearchParams({ response_type: "code", client_id: env("DISCORD_CLIENT_ID") as string, redirect_uri: env("DISCORD_REDIRECT_URI") as string, - scope: ["identify", "email"].join("%20"), + scope: ["identify", "email"].join(" "), prompt: "consent", // Always ask user to select account integration_type: "1", // USER_INSTALL + state, }); - // @ts-expect-error - req.state = state; + reply.setCookie("oauth_state", state, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); const url = new URL( `/oauth2/authorize?${params.toString()}`, diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index 4ddbad8..afaad4e 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -22,13 +22,17 @@ const GithubOauthController = { state: string; }; - // if (error) { - // req.log.warn("OAuth error: " + error); - // return reply.status(400).send("OAuth error: " + error); - // } + const storedState = req.cookies["oauth_state"]; + const verifier = req.cookies["oauth_verifier"]; - // @ts-expect-error - if (state !== req.state) { + if (!storedState || !verifier) { + req.log.warn("Missing state or code verifier in cookies"); + return reply + .status(400) + .send("Missing state or code verifier in cookies"); + } + + if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); return reply.status(400).send("State mismatch. Possible CSRF attack"); } @@ -45,6 +49,7 @@ const GithubOauthController = { client_id: env("GITHUB_CLIENT_ID"), client_secret: env("GITHUB_CLIENT_SECRET"), code, + code_verifier: verifier, redirect_uri: env("GITHUB_REDIRECT_URI"), }), }, @@ -89,14 +94,14 @@ const GithubOauthController = { const githubUser = GithubUserSchema.parse(userData); if (!githubUser.id) { - req.log.warn("Google user ID not found"); - return reply.status(400).send("Google user ID not found"); + req.log.warn("Github user ID not found"); + return reply.status(400).send("Github user ID not found"); } const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { - provider: OAuthProvider.GOOGLE, + provider: OAuthProvider.GITHUB, provider_user_id: githubUser.id, }, }, @@ -109,7 +114,7 @@ const GithubOauthController = { }, }, create: { - provider: OAuthProvider.GOOGLE, + provider: OAuthProvider.GITHUB, user_id: null, // Will be linked to a user account later provider_user_id: githubUser.id, provider_username: githubUser.login, @@ -204,7 +209,7 @@ const GithubOauthController = { data: user, }); }, - generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + generateUrl: async (_: FastifyRequest, reply: FastifyReply) => { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = crypto.randomBytes(32).toString("hex"); @@ -218,8 +223,19 @@ const GithubOauthController = { code_challenge: challenge, }); - // @ts-expect-error - req.state = state; + reply.setCookie("oauth_state", state, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); + + reply.setCookie("oauth_verifier", verifier, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); const url = new URL( `/login/oauth/authorize?${params.toString()}`, diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 6372672..f9fb80d 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -38,8 +38,9 @@ const GoogleOauthController = { return reply.status(400).send(`OAuth error: ${error}`); } - // @ts-expect-error - if (state !== req.state) { + const storedState = req.cookies["oauth_state"]; + + if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); return reply.status(400).send("State mismatch. Possible CSRF attack"); } @@ -60,6 +61,7 @@ const GoogleOauthController = { return reply.status(400).send("Google user ID not found"); } + const googleUsername = googleUser.email.split("@")[0] as string; const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { @@ -68,7 +70,7 @@ const GoogleOauthController = { }, }, update: { - provider_username: googleUser.name, + provider_username: googleUsername, provider_avatar_url: googleUser.picture, provider_metadata: { email: googleUser.email, @@ -81,6 +83,7 @@ const GoogleOauthController = { provider_username: googleUser.name, provider_avatar_url: googleUser.picture, provider_metadata: { + name: googleUser.name, email: googleUser.email, }, }, @@ -95,7 +98,7 @@ const GoogleOauthController = { }, create: { email: googleUser.email, - nickname: googleUser.name, + nickname: googleUsername, }, select: { id: true, @@ -157,7 +160,7 @@ const GoogleOauthController = { }, }; - redis.set( + await redis.set( `session:${user.id}`, JSON.stringify(session), "EX", @@ -177,8 +180,12 @@ const GoogleOauthController = { const state = crypto.randomBytes(32).toString("hex"); // Store state in the session - // @ts-expect-error - req.state = state; + reply.setCookie("oauth_state", state, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); // Generate a url that asks permissions for the Drive activity and Google Calendar scope const authorizationUrl = oauth2Client.generateAuthUrl({ diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 6f834df..40a0815 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -178,7 +178,7 @@ export default { }, }; - redis.set( + await redis.set( `session:${user.id}`, JSON.stringify(session), "EX", @@ -282,16 +282,13 @@ export default { throw new NotFoundError(req.t("Session not found")); } - const userSession = JSON.parse(rawUserSession) as { - refreshId: string; - version: number; - }; + const userSession = JSON.parse(rawUserSession) as Session; if (userSession.refreshId !== refreshId) { throw new UnauthorizedError(req.t("Invalid refresh token")); } - const newVersion = userSession.version + 1; + const newVersion = userSession.v + 1; const tokens = createTokens({ ...subject }, newVersion); @@ -397,7 +394,11 @@ export default { v: session.v + 1, }; - redis.set(`session:${userId}`, JSON.stringify(updatedSession), "KEEPTTL"); + await redis.set( + `session:${userId}`, + JSON.stringify(updatedSession), + "KEEPTTL", + ); return reply.status(204).send(); } catch (e) { diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index df45c6b..6f6338a 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -1,27 +1,17 @@ import type { FastifyInstance } from "fastify"; -import DiscordOauthController from "../controllers/discord-oauth.controller"; -import GithubOauthController from "../controllers/github-oauth.controller"; -import GoogleOauthController from "../controllers/google-oauth.controller"; +import jwtPlugin from "@/plugins/jwt"; + import AuthController from "../controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { f.post("/signup", AuthController.signup); - f.post("/login", AuthController.login); - f.post("/refresh", AuthController.refresh); + await f.register(jwtPlugin); + // Protected routes + f.post("/refresh", AuthController.refresh); f.delete("/revoke", AuthController.revoke); - f.delete("/logout", AuthController.logout); - - f.get("/url/google", GoogleOauthController.generateUrl); - f.get("/callback/google", GoogleOauthController.callback); - - f.get("/url/github", GithubOauthController.generateUrl); - f.get("/callback/github", GithubOauthController.callback); - - f.get("/url/discord", DiscordOauthController.generateUrl); - f.get("/callback/discord", DiscordOauthController.callback); } diff --git a/modules/auth/routes/oauth.router.ts b/modules/auth/routes/oauth.router.ts new file mode 100644 index 0000000..863bc1e --- /dev/null +++ b/modules/auth/routes/oauth.router.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from "fastify"; + +import DiscordOauthController from "../controllers/discord-oauth.controller"; +import GithubOauthController from "../controllers/github-oauth.controller"; +import GoogleOauthController from "../controllers/google-oauth.controller"; + +export default async function oauthRouter(f: FastifyInstance) { + f.get("/url/google", GoogleOauthController.generateUrl); + f.get("/callback/google", GoogleOauthController.callback); + + f.get("/url/github", GithubOauthController.generateUrl); + f.get("/callback/github", GithubOauthController.callback); + + f.get("/url/discord", DiscordOauthController.generateUrl); + f.get("/callback/discord", DiscordOauthController.callback); +} diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index c251344..f422c96 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -4,13 +4,10 @@ import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; -const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 10; - export const createTokens = (sub: unknown, version: number = 1) => { const now = Math.floor(Date.now() / 1000); const accessExp = now + jwtConfig.access.expiresIn; - const refreshExp = - now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; + const refreshExp = now + jwtConfig.refresh.expiresIn; const jti = crypto.randomUUID(); const accessToken = jwt.sign( diff --git a/package.json b/package.json index 92cd1fb..8201001 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "biome:lint": "biome lint" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcrypt": "^6.0.0", diff --git a/plugins/i18n.ts b/plugins/i18n.ts index ba634c2..525da34 100644 --- a/plugins/i18n.ts +++ b/plugins/i18n.ts @@ -18,7 +18,7 @@ await i18next }, }); -export default fp(async (f) => { +export default fp((f) => { // attach i18next middleware to raw req/res f.addHook("onRequest", (req, reply, done) => { middleware.handle(i18next)(req.raw, reply.raw, done); diff --git a/plugins/jwt.ts b/plugins/jwt.ts index 93eff85..9e2679a 100644 --- a/plugins/jwt.ts +++ b/plugins/jwt.ts @@ -6,30 +6,23 @@ import redis from "@/database/redis/client"; import type { Session } from "@/modules/auth/models/Session"; import type { JwtSubject } from "@/modules/auth/types/jwt"; -export default fp(async (f) => { +export default fp((f) => { f.decorateRequest("user", null); f.addHook("preHandler", async (req, reply) => { - const { pathname } = new URL(req.url, `http://${req.headers.host}`); - - // Skip authentication for public routes - if (publicRoutes.some((route) => pathname.startsWith(route))) { - return; - } - - const authHeader = req.headers.authorization; + try { + const authHeader = req.headers.authorization; - if (!authHeader) { - throw new jwt.JsonWebTokenError(req.t("Header missing")); - } + if (!authHeader) { + throw new jwt.JsonWebTokenError(req.t("Header missing")); + } - const token = authHeader.split(" ")[1]; + const token = authHeader.split(" ")[1]; - if (!token) { - throw new jwt.JsonWebTokenError(req.t("No token provided")); - } + if (!token) { + throw new jwt.JsonWebTokenError(req.t("No token provided")); + } - try { const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; @@ -62,5 +55,3 @@ export default fp(async (f) => { } }); }); - -const publicRoutes = ["/1/auth/signup", "/1/auth/login"]; diff --git a/plugins/zod.ts b/plugins/zod.ts deleted file mode 100644 index de5ad96..0000000 --- a/plugins/zod.ts +++ /dev/null @@ -1,7 +0,0 @@ -import fp from "fastify-plugin"; - -export default fp(async (f) => { - f.addHook("preHandler", async (req) => { - const _lng = req.query; - }); -}); From c8e67350279230b805d4182d918d3b542cd83c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 22:28:02 -0300 Subject: [PATCH 10/15] fix: biome check --- modules/auth/controllers/discord-oauth.controller.ts | 2 +- modules/auth/controllers/github-oauth.controller.ts | 4 ++-- modules/auth/controllers/google-oauth.controller.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 8a81681..5b7316d 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -18,7 +18,7 @@ const DiscordOauthController = { state: string; }; - const storedState = req.cookies["oauth_state"]; + const storedState = req.cookies.oauth_state; if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index afaad4e..fe41ea9 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -22,8 +22,8 @@ const GithubOauthController = { state: string; }; - const storedState = req.cookies["oauth_state"]; - const verifier = req.cookies["oauth_verifier"]; + const storedState = req.cookies.oauth_state; + const verifier = req.cookies.oauth_verifier; if (!storedState || !verifier) { req.log.warn("Missing state or code verifier in cookies"); diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index f9fb80d..66fc349 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -38,7 +38,7 @@ const GoogleOauthController = { return reply.status(400).send(`OAuth error: ${error}`); } - const storedState = req.cookies["oauth_state"]; + const storedState = req.cookies.oauth_state; if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); @@ -173,7 +173,7 @@ const GoogleOauthController = { data: user, }); }, - generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + generateUrl: async (_req: FastifyRequest, reply: FastifyReply) => { const scopes: Array = ["openid", "profile", "email"]; // Generate a secure random state value. From 79c368b2fd55d8125a0a83e78fe32a094a91efc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Mon, 4 May 2026 00:19:05 -0300 Subject: [PATCH 11/15] fix: some fixes --- modules/auth/controllers/discord-oauth.controller.ts | 5 ++++- modules/auth/controllers/github-oauth.controller.ts | 4 ++-- modules/auth/controllers/google-oauth.controller.ts | 7 ++++++- modules/auth/controllers/index.controller.ts | 1 + routes/v1.ts | 4 ++++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 5b7316d..2d2067b 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -88,6 +88,9 @@ const DiscordOauthController = { return reply.status(400).send("Discord user ID not found"); } + const discordEmail = + discordUser.email || `${discordUser.id}@discord.oauth.local`; + const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { @@ -219,7 +222,7 @@ const DiscordOauthController = { state, }); - reply.setCookie("oauth_state", state, { + reply.setCookie("dojoh.oauth_state", state, { httpOnly: true, secure: true, sameSite: "lax", diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index fe41ea9..cb8c3d5 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -223,14 +223,14 @@ const GithubOauthController = { code_challenge: challenge, }); - reply.setCookie("oauth_state", state, { + reply.setCookie("dojoh.oauth_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 60 * 5, // 5m }); - reply.setCookie("oauth_verifier", verifier, { + reply.setCookie("dojoh.oauth_verifier", verifier, { httpOnly: true, secure: true, sameSite: "lax", diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 66fc349..6faedf4 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -180,7 +180,7 @@ const GoogleOauthController = { const state = crypto.randomBytes(32).toString("hex"); // Store state in the session - reply.setCookie("oauth_state", state, { + reply.setCookie("dojoh.oauth_state", state, { httpOnly: true, secure: true, sameSite: "lax", @@ -202,6 +202,11 @@ const GoogleOauthController = { return reply.redirect(authorizationUrl); }, + oneTap: async (req: FastifyRequest, reply: FastifyReply) => { + const { credential } = req.body as { + credential: string; + }; + }, }; export default GoogleOauthController; diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 40a0815..4a70fd3 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -391,6 +391,7 @@ export default { const updatedSession: Session = { ...session, + refreshId: crypto.randomUUID(), v: session.v + 1, }; diff --git a/routes/v1.ts b/routes/v1.ts index dce929d..547bd27 100644 --- a/routes/v1.ts +++ b/routes/v1.ts @@ -4,10 +4,14 @@ export default async function version_1(f: FastifyInstance) { const { default: authRouter } = await import( "@/modules/auth/routes/index.router" ); + const { default: oauthRouter } = await import( + "@/modules/auth/routes/oauth.router" + ); const { default: kumiteRouter } = await import( "@/modules/kumite/routes/index.router" ); f.register(authRouter, { prefix: "/auth" }); + f.register(oauthRouter, { prefix: "/oauth" }); f.register(kumiteRouter, { prefix: "/kumite" }); } From 4d504680cd2bf3510fed73c039b297390827491e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Wed, 6 May 2026 23:44:44 -0300 Subject: [PATCH 12/15] fix(Root): oauth working --- config/database.ts | 8 +- .../migrations/20260502141005/migration.sql | 14 - .../migrations/20260502151239/migration.sql | 5 - .../migration.sql | 15 +- database/prisma/schema.prisma | 32 +- index.ts | 3 + .../controllers/discord-oauth.controller.ts | 108 +- .../controllers/github-oauth.controller.ts | 108 +- .../controllers/google-oauth.controller.ts | 126 +- modules/auth/routes/index.router.ts | 13 +- modules/auth/routes/oauth.router.ts | 12 +- modules/auth/schemas/github-user.schema.ts | 2 +- package.json | 2 + plugins/cors.ts | 47 + pnpm-lock.yaml | 3100 +++++++++++++++++ 15 files changed, 3482 insertions(+), 113 deletions(-) delete mode 100644 database/prisma/migrations/20260502141005/migration.sql delete mode 100644 database/prisma/migrations/20260502151239/migration.sql rename database/prisma/migrations/{20260418235736 => 20260507020539}/migration.sql (84%) create mode 100644 plugins/cors.ts create mode 100644 pnpm-lock.yaml diff --git a/config/database.ts b/config/database.ts index d9f22ba..9e79498 100644 --- a/config/database.ts +++ b/config/database.ts @@ -4,15 +4,15 @@ export default { postgres: { host: env("POSTGRES_HOST", "localhost"), port: env("POSTGRES_PORT", 5432), - username: env("POSTGRES_USERNAME", "postgres"), - password: env("POSTGRES_PASSWORD", "password"), - database: env("POSTGRES_DATABASE", "dojoh"), + username: env("POSTGRES_USERNAME", ""), + password: env("POSTGRES_PASSWORD", ""), + database: env("POSTGRES_DATABASE", ""), }, redis: { host: env("REDIS_HOST", "localhost"), port: env("REDIS_PORT", 6379), - username: env("REDIS_USERNAME", "default"), + username: env("REDIS_USERNAME", ""), password: env("REDIS_PASSWORD", ""), }, }; diff --git a/database/prisma/migrations/20260502141005/migration.sql b/database/prisma/migrations/20260502141005/migration.sql deleted file mode 100644 index 5f7a9c2..0000000 --- a/database/prisma/migrations/20260502141005/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- AlterEnum --- This migration adds more than one value to an enum. --- With PostgreSQL versions 11 and earlier, this is not possible --- in a single migration. This can be worked around by creating --- multiple migrations, each migration adding only one value to --- the enum. - - -ALTER TYPE "OAuthProvider" ADD VALUE 'DISCORD'; -ALTER TYPE "OAuthProvider" ADD VALUE 'NATIVE'; - --- AlterTable -ALTER TABLE "OAuthAccount" ADD COLUMN "provider_metadata" JSONB, -ALTER COLUMN "provider_avatar_url" DROP NOT NULL; diff --git a/database/prisma/migrations/20260502151239/migration.sql b/database/prisma/migrations/20260502151239/migration.sql deleted file mode 100644 index cdd9690..0000000 --- a/database/prisma/migrations/20260502151239/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "OAuthAccount" ALTER COLUMN "user_id" DROP NOT NULL; - --- AddForeignKey -ALTER TABLE "OAuthAccount" ADD CONSTRAINT "OAuthAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/database/prisma/migrations/20260418235736/migration.sql b/database/prisma/migrations/20260507020539/migration.sql similarity index 84% rename from database/prisma/migrations/20260418235736/migration.sql rename to database/prisma/migrations/20260507020539/migration.sql index b4d0bf2..11dc08e 100644 --- a/database/prisma/migrations/20260418235736/migration.sql +++ b/database/prisma/migrations/20260507020539/migration.sql @@ -2,7 +2,7 @@ CREATE TYPE "ImageVariantType" AS ENUM ('XS', 'S', 'M', 'L', 'XL'); -- CreateEnum -CREATE TYPE "OAuthProvider" AS ENUM ('GOOGLE', 'GITHUB'); +CREATE TYPE "OAuthProvider" AS ENUM ('GOOGLE', 'GITHUB', 'DISCORD', 'NATIVE'); -- CreateTable CREATE TABLE "Image" ( @@ -49,11 +49,12 @@ CREATE TABLE "User" ( -- CreateTable CREATE TABLE "OAuthAccount" ( "id" SERIAL NOT NULL, - "user_id" INTEGER NOT NULL, + "user_id" INTEGER, "provider" "OAuthProvider" NOT NULL, "provider_user_id" TEXT NOT NULL, "provider_username" TEXT NOT NULL, - "provider_avatar_url" TEXT NOT NULL, + "provider_avatar_url" TEXT, + "provider_metadata" JSONB, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3), @@ -66,6 +67,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); -- CreateIndex CREATE UNIQUE INDEX "User_nickname_key" ON "User"("nickname"); +-- CreateIndex +CREATE UNIQUE INDEX "User_avatar_id_key" ON "User"("avatar_id"); + -- CreateIndex CREATE INDEX "User_email_idx" ON "User"("email"); @@ -82,7 +86,10 @@ CREATE UNIQUE INDEX "OAuthAccount_user_id_provider_key" ON "OAuthAccount"("user_ CREATE UNIQUE INDEX "OAuthAccount_provider_provider_user_id_key" ON "OAuthAccount"("provider", "provider_user_id"); -- AddForeignKey -ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "User" ADD CONSTRAINT "User_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES "Image"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccount" ADD CONSTRAINT "OAuthAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/database/prisma/schema.prisma b/database/prisma/schema.prisma index 71b88b9..06e0e69 100644 --- a/database/prisma/schema.prisma +++ b/database/prisma/schema.prisma @@ -14,9 +14,11 @@ model Image { mime_type String file_size Int - created_at DateTime @default(now()) - ImageVariant ImageVariant[] - User User[] + created_at DateTime @default(now()) + + variants ImageVariant[] + + owner User? } enum ImageVariantType { @@ -29,7 +31,7 @@ enum ImageVariantType { model ImageVariant { id Int @id @default(autoincrement()) - image Image @relation(fields: [image_id], references: [id]) + image Image @relation(fields: [image_id], references: [id], onDelete: Cascade) image_id Int variant ImageVariantType @@ -43,16 +45,18 @@ model ImageVariant { } model User { - id Int @id @default(autoincrement()) - email String @unique - nickname String @unique - password String? - avatar Image? @relation(fields: [avatar_id], references: [id]) - avatar_id Int? - - created_at DateTime @default(now()) - updated_at DateTime? @updatedAt - deleted_at DateTime? + id Int @id @default(autoincrement()) + email String @unique + nickname String @unique + password String? + + avatar Image? @relation(fields: [avatar_id], references: [id], onDelete: SetNull) + avatar_id Int? @unique + + created_at DateTime @default(now()) + updated_at DateTime? @updatedAt + deleted_at DateTime? + oauthAccounts OAuthAccount[] @@index([email]) diff --git a/index.ts b/index.ts index 73bf513..8f9603c 100644 --- a/index.ts +++ b/index.ts @@ -2,12 +2,15 @@ import fastifyCookie from "@fastify/cookie"; import Fastify from "fastify"; import env from "./config/env"; +import corsPlugin from "./plugins/cors"; import i18nPlugin from "./plugins/i18n"; const f = Fastify({ logger: true, }); +f.register(corsPlugin); + f.register(fastifyCookie, { secret: env("COOKIE_SECRET", undefined), }); diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 2d2067b..8119c82 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -1,5 +1,7 @@ import crypto from "node:crypto"; +import { notionistsNeutral } from "@dicebear/collection"; +import { createAvatar } from "@dicebear/core"; import { OAuthProvider } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; @@ -12,13 +14,13 @@ import { DiscordUserSchema } from "../schemas/discord-user.schema"; import { createTokens } from "../serivces/jwt.service"; const DiscordOauthController = { - callback: async (req: FastifyRequest, reply: FastifyReply) => { + async callback(req: FastifyRequest, reply: FastifyReply) { const { code, state } = req.query as { code: string; state: string; }; - const storedState = req.cookies.oauth_state; + const storedState = req.cookies["dojoh.oauth_state"]; if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); @@ -88,8 +90,15 @@ const DiscordOauthController = { return reply.status(400).send("Discord user ID not found"); } + console.debug("DISCORD_USER_INFO", userData); + const discordEmail = - discordUser.email || `${discordUser.id}@discord.oauth.local`; + discordUser.email || + `${discordUser.username}+${discordUser.id}@discord.oauth.local`; + + const discordAvatar = discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null; const oauthAccount = await prisma.oAuthAccount.upsert({ where: { @@ -104,7 +113,7 @@ const DiscordOauthController = { ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` : null, provider_metadata: { - email: discordUser.email, + email: discordEmail, lng: discordUser.locale, discriminator: discordUser.discriminator, }, @@ -114,33 +123,68 @@ const DiscordOauthController = { user_id: null, // Will be linked to a user account later provider_user_id: discordUser.id, provider_username: discordUser.username, - provider_avatar_url: discordUser.avatar - ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` - : null, + provider_avatar_url: discordAvatar, provider_metadata: { - email: discordUser.email, + email: discordEmail, locale: discordUser.locale, discriminator: discordUser.discriminator, }, }, }); + if (!discordAvatar) { + // Create a random Notionish avatar if Discord doesn't provide one (which is rare, but just in case) + const avatar = createAvatar(notionistsNeutral, { + seed: discordUser.id, + size: 128, + }); + + discordUser.avatar = avatar.toString(); + } + const user = await prisma.user.upsert({ where: { - email: discordUser.email, + email: discordEmail, }, update: { - // Do nothing, we don't want to overwrite existing user data + avatar: { + upsert: { + update: { + original_url: discordAvatar as string, + mime_type: "image/jpeg", // Assuming JPEG, can be updated later if needed + file_size: 0, // We don't have this info from Discord, can be updated later if needed + height_original: 0, // We don't have this info from Discord, can be updated later if needed + width_original: 0, // We don't have this info from Discord, can be updated later if needed + }, + create: { + original_url: discordAvatar as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, + }, }, create: { - email: discordUser.email || "", + email: discordEmail, nickname: discordUser.username, + avatar: { + create: { + original_url: discordAvatar as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, }, select: { id: true, email: true, nickname: true, password: false, + avatar: true, }, }); @@ -203,13 +247,43 @@ const DiscordOauthController = { 60 * 60 * 24 * 7, // 7 days ); - return reply.send({ - message: req.t("Logged in with Discord successfully!"), - tokens: jwtTokens, - data: user, - }); + const isDev = env("NODE_ENV") === "development"; + const isProd = env("NODE_ENV") === "production"; + if (!isDev && !isProd) { + throw new Error( + `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + ); + } + + const secure = isProd; + reply + .setCookie("dojoh.access_token", jwtTokens.accessToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24, // 1 day + }) + .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + }) + .setCookie("dojoh.session", JSON.stringify(user), { + path: "/", + httpOnly: false, // accessible from frontend if needed + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); + + return reply.redirect( + env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), + ); }, - generateUrl: async (_: FastifyRequest, reply: FastifyReply) => { + async redirect(_: FastifyRequest, reply: FastifyReply) { const state = crypto.randomBytes(32).toString("hex"); const params = new URLSearchParams({ diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index cb8c3d5..e3053e7 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -1,5 +1,7 @@ import crypto from "node:crypto"; +import { notionistsNeutral } from "@dicebear/collection"; +import { createAvatar } from "@dicebear/core"; import { OAuthProvider } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; @@ -16,14 +18,14 @@ import { GithubUserSchema } from "../schemas/github-user.schema"; import { createTokens } from "../serivces/jwt.service"; const GithubOauthController = { - callback: async (req: FastifyRequest, reply: FastifyReply) => { + async callback(req: FastifyRequest, reply: FastifyReply) { const { code, state } = req.query as { code: string; state: string; }; - const storedState = req.cookies.oauth_state; - const verifier = req.cookies.oauth_verifier; + const storedState = req.cookies["dojoh.oauth_state"]; + const verifier = req.cookies["dojoh.oauth_verifier"]; if (!storedState || !verifier) { req.log.warn("Missing state or code verifier in cookies"); @@ -98,50 +100,92 @@ const GithubOauthController = { return reply.status(400).send("Github user ID not found"); } + const githubEmail = + githubUser.email || + `${githubUser.login}+${githubUser.id}@github.oauth.local`; + const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { provider: OAuthProvider.GITHUB, - provider_user_id: githubUser.id, + provider_user_id: String(githubUser.id), }, }, update: { provider_username: githubUser.name, provider_avatar_url: githubUser.avatar_url || null, provider_metadata: { - email: githubUser.email, + email: githubEmail, name: githubUser.name, }, }, create: { provider: OAuthProvider.GITHUB, user_id: null, // Will be linked to a user account later - provider_user_id: githubUser.id, + provider_user_id: String(githubUser.id), provider_username: githubUser.login, provider_avatar_url: githubUser.avatar_url, provider_metadata: { - email: githubUser.email, + email: githubEmail, name: githubUser.name, }, }, }); + if (!githubUser.avatar_url) { + // Create a random Notionish avatar if GitHub doesn't provide one (which is rare, but just in case) + const avatar = createAvatar(notionistsNeutral, { + seed: String(githubUser.id), + size: 128, + }); + + githubUser.avatar_url = avatar.toString(); + } + const user = await prisma.user.upsert({ - where: githubUser.email - ? { email: githubUser.email } + where: githubEmail + ? { email: githubEmail } : { nickname: githubUser.login }, update: { - // Do nothing, we don't want to overwrite existing user data + nickname: githubUser.login, + avatar: { + upsert: { + update: { + original_url: githubUser.avatar_url as string, + mime_type: "image/jpeg", // Assuming JPEG, can be updated later if needed + file_size: 0, // We don't have this info from GitHub, can be updated later if needed + height_original: 0, // We don't have this info from GitHub, can be updated later if needed + width_original: 0, // We don't have this info from GitHub, can be updated later if needed + }, + create: { + original_url: githubUser.avatar_url as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, + }, }, create: { - email: githubUser.email ?? "", + email: githubEmail, nickname: githubUser.login, + avatar: { + create: { + original_url: githubUser.avatar_url as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, }, select: { id: true, email: true, nickname: true, password: false, + avatar: true, }, }); @@ -203,13 +247,43 @@ const GithubOauthController = { 60 * 60 * 24 * 7, // 7 days ); - return reply.send({ - message: req.t("Logged in with Github successfully!"), - tokens: jwtTokens, - data: user, - }); + const isDev = env("NODE_ENV") === "development"; + const isProd = env("NODE_ENV") === "production"; + if (!isDev && !isProd) { + throw new Error( + `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + ); + } + + const secure = isProd; + reply + .setCookie("dojoh.access_token", jwtTokens.accessToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24, // 1 day + }) + .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + }) + .setCookie("dojoh.session", JSON.stringify(user), { + path: "/", + httpOnly: false, // accessible from frontend if needed + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); + + return reply.redirect( + env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), + ); }, - generateUrl: async (_: FastifyRequest, reply: FastifyReply) => { + async redirect(_: FastifyRequest, reply: FastifyReply) { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = crypto.randomBytes(32).toString("hex"); diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 6faedf4..cea950b 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -1,5 +1,7 @@ import crypto from "node:crypto"; +import { notionistsNeutral } from "@dicebear/collection"; +import { createAvatar } from "@dicebear/core"; import { OAuthProvider } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; import { google } from "googleapis"; @@ -17,16 +19,14 @@ import { createTokens } from "../serivces/jwt.service"; * from the client_secret.json file. To get these credentials for your application, visit * https://console.cloud.google.com/apis/credentials. */ -const oauth2Client = new google.auth.OAuth2( - { - clientId: env("GOOGLE_CLIENT_ID") as string, - }, - env("GOOGLE_CLIENT_SECRET") as string, - env("GOOGLE_REDIRECT_URI") as string, -); +const oauth2Client = new google.auth.OAuth2({ + clientId: env("GOOGLE_CLIENT_ID") as string, + clientSecret: env("GOOGLE_CLIENT_SECRET") as string, + redirectUri: env("GOOGLE_REDIRECT_URI") as string, +}); const GoogleOauthController = { - callback: async (req: FastifyRequest, reply: FastifyReply) => { + async callback(req: FastifyRequest, reply: FastifyReply) { const { code, state, error } = req.query as { code: string; state: string; @@ -38,7 +38,7 @@ const GoogleOauthController = { return reply.status(400).send(`OAuth error: ${error}`); } - const storedState = req.cookies.oauth_state; + const storedState = req.cookies["dojoh.oauth_state"]; if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); @@ -61,7 +61,14 @@ const GoogleOauthController = { return reply.status(400).send("Google user ID not found"); } - const googleUsername = googleUser.email.split("@")[0] as string; + const googleUsername = + (googleUser.email.split("@")[0] as string) || + `google_user_${googleUser.id}`; + + const googleEmail = + googleUser.email || + `${googleUsername}+${googleUser.id}@google.oauth.local`; + const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { @@ -73,7 +80,7 @@ const GoogleOauthController = { provider_username: googleUsername, provider_avatar_url: googleUser.picture, provider_metadata: { - email: googleUser.email, + email: googleEmail, }, }, create: { @@ -84,27 +91,65 @@ const GoogleOauthController = { provider_avatar_url: googleUser.picture, provider_metadata: { name: googleUser.name, - email: googleUser.email, + email: googleEmail, }, }, }); + if (!googleUser.picture) { + // Create a random Notionish avatar if Google doesn't provide one (which is rare, but just in case) + const avatar = createAvatar(notionistsNeutral, { + seed: googleUser.id, + size: 128, + }); + + googleUser.picture = avatar.toString(); + } + const user = await prisma.user.upsert({ where: { - email: googleUser.email, + email: googleEmail, }, update: { - // Do nothing, we don't want to overwrite existing user data + nickname: googleUsername, + avatar: { + upsert: { + update: { + original_url: googleUser.picture as string, + mime_type: "image/jpeg", // Assuming JPEG, can be updated later if needed + file_size: 0, // We don't have this info from Google, can be updated later if needed + height_original: 0, // We don't have this info from Google, can be updated later if needed + width_original: 0, // We don't have this info from Google, can be updated later if needed + }, + create: { + original_url: googleUser.picture as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, + }, }, create: { - email: googleUser.email, + email: googleEmail, nickname: googleUsername, + avatar: { + create: { + original_url: googleUser.picture as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, }, select: { id: true, email: true, nickname: true, password: false, + avatar: true, }, }); @@ -167,13 +212,43 @@ const GoogleOauthController = { 60 * 60 * 24 * 7, // 7 days ); - return reply.send({ - message: req.t("Logged in with Google successfully!"), - tokens: jwtTokens, - data: user, - }); + const isDev = env("NODE_ENV") === "development"; + const isProd = env("NODE_ENV") === "production"; + if (!isDev && !isProd) { + throw new Error( + `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + ); + } + + const secure = isProd; + reply + .setCookie("dojoh.access_token", jwtTokens.accessToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24, // 1 day + }) + .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + }) + .setCookie("dojoh.session", JSON.stringify(user), { + path: "/", + httpOnly: false, // accessible from frontend if needed + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); + + return reply.redirect( + env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), + ); }, - generateUrl: async (_req: FastifyRequest, reply: FastifyReply) => { + async redirect(_req: FastifyRequest, reply: FastifyReply) { const scopes: Array = ["openid", "profile", "email"]; // Generate a secure random state value. @@ -198,14 +273,15 @@ const GoogleOauthController = { include_granted_scopes: true, // Include the state parameter to reduce the risk of CSRF attacks. state: state, + redirect_uri: env("GOOGLE_REDIRECT_URI") as string, }); return reply.redirect(authorizationUrl); }, - oneTap: async (req: FastifyRequest, reply: FastifyReply) => { - const { credential } = req.body as { - credential: string; - }; + async oneTap(req: FastifyRequest, reply: FastifyReply) { + // const { credential } = req.body as { + // credential: string; + // }; }, }; diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index 6f6338a..acf7782 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -6,12 +6,13 @@ import AuthController from "../controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { f.post("/signup", AuthController.signup); - f.post("/login", AuthController.login); - - await f.register(jwtPlugin); + f.post("/local", AuthController.login); // Protected routes - f.post("/refresh", AuthController.refresh); - f.delete("/revoke", AuthController.revoke); - f.delete("/logout", AuthController.logout); + await f.register((f) => { + f.register(jwtPlugin); + f.post("/refresh", AuthController.refresh); + f.delete("/revoke", AuthController.revoke); + f.delete("/logout", AuthController.logout); + }); } diff --git a/modules/auth/routes/oauth.router.ts b/modules/auth/routes/oauth.router.ts index 863bc1e..3fa0383 100644 --- a/modules/auth/routes/oauth.router.ts +++ b/modules/auth/routes/oauth.router.ts @@ -5,12 +5,12 @@ import GithubOauthController from "../controllers/github-oauth.controller"; import GoogleOauthController from "../controllers/google-oauth.controller"; export default async function oauthRouter(f: FastifyInstance) { - f.get("/url/google", GoogleOauthController.generateUrl); - f.get("/callback/google", GoogleOauthController.callback); + f.get("/google", GoogleOauthController.redirect); + f.get("/google/callback", GoogleOauthController.callback); - f.get("/url/github", GithubOauthController.generateUrl); - f.get("/callback/github", GithubOauthController.callback); + f.get("/github", GithubOauthController.redirect); + f.get("/github/callback", GithubOauthController.callback); - f.get("/url/discord", DiscordOauthController.generateUrl); - f.get("/callback/discord", DiscordOauthController.callback); + f.get("/discord", DiscordOauthController.redirect); + f.get("/discord/callback", DiscordOauthController.callback); } diff --git a/modules/auth/schemas/github-user.schema.ts b/modules/auth/schemas/github-user.schema.ts index 08dac7b..8c0b0e6 100644 --- a/modules/auth/schemas/github-user.schema.ts +++ b/modules/auth/schemas/github-user.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const GithubUserSchema = z.object({ - id: z.string(), + id: z.number(), login: z.string(), avatar_url: z.url().optional(), email: z.email().optional(), diff --git a/package.json b/package.json index 8201001..126fe58 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "biome:lint": "biome lint" }, "dependencies": { + "@dicebear/collection": "^9.4.2", + "@dicebear/core": "^9.4.2", "@fastify/cookie": "^11.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", diff --git a/plugins/cors.ts b/plugins/cors.ts new file mode 100644 index 0000000..7c45e0b --- /dev/null +++ b/plugins/cors.ts @@ -0,0 +1,47 @@ +import fp from "fastify-plugin"; + +import env from "@/config/env"; + +const allowedDomains = { + development: ["http://127.0.0.1:3000", "http://127.0.0.1:8080"], + production: ["https://dojoh.dev", "https://www.dojoh.dev"], +}; + +export default fp((f) => { + f.addHook("preHandler", async (req, reply) => { + const origin = req.headers.origin; + const envName = env("NODE_ENV", "development"); + + // Allow browser origins + if (origin && allowedDomains[envName].includes(origin)) { + reply.header("Access-Control-Allow-Origin", origin); + } + + // Required for credentialed requests + reply.header("Access-Control-Allow-Credentials", "true"); + + // Prevent cache poisoning issues + reply.header("Vary", "Origin"); + + reply.header( + "Access-Control-Allow-Methods", + ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"].join(", "), + ); + reply.header( + "Access-Control-Allow-Headers", + [ + "Content-Type", + "Authorization", + "Accept", + "Origin", + "X-Requested-With", + ].join(", "), + ); + + // Handle preflight + if (req.method === "OPTIONS") { + reply.status(200).send(); + return; + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..20dda70 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3100 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dicebear/collection': + specifier: ^9.4.2 + version: 9.4.2(@dicebear/core@9.4.2) + '@dicebear/core': + specifier: ^9.4.2 + version: 9.4.2 + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@prisma/adapter-pg': + specifier: ^7.8.0 + version: 7.8.0 + '@prisma/client': + specifier: ^7.8.0 + version: 7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + fastify: + specifier: ^5.8.4 + version: 5.8.5 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + googleapis: + specifier: ^171.4.0 + version: 171.4.0 + i18next: + specifier: ^26.0.3 + version: 26.0.9(typescript@6.0.3) + i18next-fs-backend: + specifier: ^2.6.3 + version: 2.6.5 + i18next-http-middleware: + specifier: ^3.9.2 + version: 3.9.6 + install: + specifier: ^0.13.0 + version: 0.13.0 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + prisma: + specifier: ^7.8.0 + version: 7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@biomejs/biome': + specifier: 2.4.10 + version: 2.4.10 + '@commitlint/cli': + specifier: ^20.5.0 + version: 20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3) + '@commitlint/config-conventional': + specifier: ^20.5.0 + version: 20.5.3 + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 + '@types/bun': + specifier: ^1.3.11 + version: 1.3.13 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@typescript/native-preview': + specifier: ^7.0.0-dev.20260403.1 + version: 7.0.0-dev.20260506.1 + lefthook: + specifier: ^2.1.4 + version: 2.1.6 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.4.10': + resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.10': + resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.10': + resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.10': + resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.10': + resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.10': + resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.10': + resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.10': + resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.10': + resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@dicebear/adventurer-neutral@9.4.2': + resolution: {integrity: sha512-5xgkG/mNL4j3Q4SJGQLBU/KnU90tng8Ze5ofThD+55wi0oeY/nSAUowg6UFCmHrktjifj/MEx3CQqbpcPWtfIA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/adventurer@9.4.2': + resolution: {integrity: sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/avataaars-neutral@9.4.2': + resolution: {integrity: sha512-/eNrp0YCNJRwQXqOloLm1+3Ss2C+pMpUQIGkbEnGsP1UK+13Ge80ggDDof1HpdqvG9HAZcKa7hnbG/0HSwyDSw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/avataaars@9.4.2': + resolution: {integrity: sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/big-ears-neutral@9.4.2': + resolution: {integrity: sha512-M8Ozmzza4eY4hpLOYULgJxMYmBA0CsBnrE15/xw6LZkEREXnrX5z0NJsf8hUfdyF6BWZ+RBgzoiav32DAC5zcg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/big-ears@9.4.2': + resolution: {integrity: sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/big-smile@9.4.2': + resolution: {integrity: sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/bottts-neutral@9.4.2': + resolution: {integrity: sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/bottts@9.4.2': + resolution: {integrity: sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/collection@9.4.2': + resolution: {integrity: sha512-KArubv7if8H7j9sIfpDK2hJJqrdNVR5zMPAMOSpIU2JPyXx8TC9o5wsmXb8il5wOHgaS9Q/cla7jUNIiDD7Gsg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/core@9.4.2': + resolution: {integrity: sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==} + engines: {node: '>=18.0.0'} + + '@dicebear/croodles-neutral@9.4.2': + resolution: {integrity: sha512-oG5IeUdtiYshQ89gkAVcl5w3xAEi5UZX2fTzIyelpBPCG176l7VuuFzlxi2umnB3E6LVHYy06DXvUo/p+rXB2Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/croodles@9.4.2': + resolution: {integrity: sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/dylan@9.4.2': + resolution: {integrity: sha512-1vQvRu9x9DrwFxhFaIU2rf0EUL04yDTbAt7fHyAjM0mEsKzTD4mRNf95tCRuavCoW6W48u7A/OY6jyIub6kxLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/fun-emoji@9.4.2': + resolution: {integrity: sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/glass@9.4.2': + resolution: {integrity: sha512-z5qUogHQ1b6UJ2zCqT848mU2U9DKbVDhiX6GPDjD7tYLisCCJVisH9p6WyNdHvflUd4SHkA6gRqVJIh2v2HnTA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/icons@9.4.2': + resolution: {integrity: sha512-QSMMz0NA03ypSGhXC8HQX8FSj8lYT+/5yqH+/N03OH2IjL0q7wwGZ7nqsrtlRp76O5WqMTwGfSbTUUYPjFr+Xw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/identicon@9.4.2': + resolution: {integrity: sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/initials@9.4.2': + resolution: {integrity: sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/lorelei-neutral@9.4.2': + resolution: {integrity: sha512-yspanTthA5vh6iCdeLzn6xZ4yYMYRcfcxblcgSvHTF1ut0bjAXtw5SXzZ6aJTrJWiHkzYOQuTOR6GVYiW80Q7w==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/lorelei@9.4.2': + resolution: {integrity: sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/micah@9.4.2': + resolution: {integrity: sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/miniavs@9.4.2': + resolution: {integrity: sha512-wLwyFNNUnDRd3BbhSBhXR0XEpX8sG0/xDA5M/OkDoapLqZnnI48YLUSDd2N5QTAVMmcSEuZOYxkcnj7WW79vlg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/notionists-neutral@9.4.2': + resolution: {integrity: sha512-AyD9kEfVxQUwDGf4Op059gVmYIOAkTKg3dtE9h9mEKP7zl/kMy5B67BFFOo7sB0mXCjzAegZ6ekGU02E8+hIHw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/notionists@9.4.2': + resolution: {integrity: sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/open-peeps@9.4.2': + resolution: {integrity: sha512-i01tLgtp2g937T81sVeAOVlqsCtiTck/Kw20g7hN80+7xrXjOUepz2HPLy3HeiMjwjMGRy5o54kSd0/8Ht4Dqg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/personas@9.4.2': + resolution: {integrity: sha512-NJlkvI5F5gugt6t2+7QrYNTwQC7+4IQZS3vG0dYk2BncxOHax0BuLovdSdiAesTL4ZkytFYIydWmKmV2/xcUwg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/pixel-art-neutral@9.4.2': + resolution: {integrity: sha512-9e9Lz554uQvWaXV2P17ss+hPa6rTyuAKBtB8zk8ECjHiZzIl61N/KcTVLZ4dILVZwj7gYriaLo16QEqvL2GJCg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/pixel-art@9.4.2': + resolution: {integrity: sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/rings@9.4.2': + resolution: {integrity: sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/shapes@9.4.2': + resolution: {integrity: sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/thumbs@9.4.2': + resolution: {integrity: sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/toon-head@9.4.2': + resolution: {integrity: sha512-lwFeSXyAnaKnCfMt9TiJwnD1cXQUGkey/0h6i/+4TVHVMCz5/Ri5u1ynovPNHy1SnBf858QwoXHkxilGLwQX/g==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.8.0': + resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.8.0': + resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': + resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} + + '@prisma/engines@7.8.0': + resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} + + '@prisma/fetch-engine@7.8.0': + resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.8.0': + resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/bun@1.3.13': + resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-dAd7qG2J508+4CRSuoEA0EUxViIedQ0D+8xKoZiM0EQHCwww8glWYCo72UTjcRZctS3QbJY3PtGSvo3nzL4oVw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-1Q7Elncpuiozvx3HCTgFbSxNz2m2FIkO1QW5f15igcZDG3vMW4QglNflmXosc69bzYI7KfYZuaGX3yGzJkGbfg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-Q1W4DHplR2urmtPwoz9tw6XUGWRNXF+CIXJQ8ZpIZFj/OHgvTw8vkYkKFuaEao3lSjTsR4lQe/wL2Xr5K0hxuA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-MfYn1p+aOorZ2Y+7sqLvSoAXPEz/RfKgHfeYO240Udco30B4oapm7Hsq2PsS9Z2Oth/RorGjY0jLP2OhnkY2Ig==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-b+sbLBCIchbrGQNbjIvVN2qd+ieqqp/nghi0n2zOAKGPsfd5wG6ceqxWJKADdBDCohsCCGt//rZccUwFugIsyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-l59d8pZjFT7GoWpgCOy6aBcxLSALphA91X4Z/2XHo5HnM0bQ/yJjB7XMeUQZBdk5DZCdZL+sWTfmXLRggm7sFg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-dJDLSzaz2xjRYYmTSfcCepZUi3ITjQSJ6Gk5YGplMF57UmZCAGI+ns4Te/V74IJiQigXqTnyEIGorwsOqhW8gQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260506.1': + resolution: {integrity: sha512-UcEslgHBaHYPAisVQcyARDfps7nKyugmUyXcsfE1HiHcVuvZ4tBJ5C93sG1FDeHWJ9skGQ68ec+Xsx086geAfg==} + engines: {node: '>=16.20.0'} + hasBin: true + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + better-result@2.9.2: + resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bun-types@1.3.13: + resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + hasBin: true + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@8.0.1: + resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} + engines: {node: '>=18.0.0'} + + googleapis@171.4.0: + resolution: {integrity: sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + i18next-fs-backend@2.6.5: + resolution: {integrity: sha512-+7HBrlaj3UFnNC9HqiXaIlNkwNINYkgQ2PS4h7qW/WGHC9/+OU9Xi0/tdvjjXATw3YCcpmNV7S3zDD9e10pTWg==} + + i18next-http-middleware@3.9.6: + resolution: {integrity: sha512-zKOft9iNCbOM4cWxThpq9gikpCuftCFh9s7V+vXPoB0+AJw13mXZe/O2VCfZO0AKNHplFTDYkJASoK+J3iWFeQ==} + + i18next@26.0.9: + resolution: {integrity: sha512-htjWlK5lGpjIDJxUdDLD6dO2jPl/RZHBpeZC80T5yr6Lci/tN3Oq9Doh9110ihliAIb4xmb45ngM76WcbP6GjA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + install@0.13.0: + resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + lefthook-darwin-arm64@2.1.6: + resolution: {integrity: sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.1.6: + resolution: {integrity: sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.1.6: + resolution: {integrity: sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.1.6: + resolution: {integrity: sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.1.6: + resolution: {integrity: sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.1.6: + resolution: {integrity: sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.1.6: + resolution: {integrity: sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.1.6: + resolution: {integrity: sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.1.6: + resolution: {integrity: sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.1.6: + resolution: {integrity: sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A==} + cpu: [x64] + os: [win32] + + lefthook@2.1.6: + resolution: {integrity: sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==} + hasBin: true + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prisma@7.8.0: + resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@biomejs/biome@2.4.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.10 + '@biomejs/cli-darwin-x64': 2.4.10 + '@biomejs/cli-linux-arm64': 2.4.10 + '@biomejs/cli-linux-arm64-musl': 2.4.10 + '@biomejs/cli-linux-x64': 2.4.10 + '@biomejs/cli-linux-x64-musl': 2.4.10 + '@biomejs/cli-win32-arm64': 2.4.10 + '@biomejs/cli-win32-x64': 2.4.10 + + '@biomejs/cli-darwin-arm64@2.4.10': + optional: true + + '@biomejs/cli-darwin-x64@2.4.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.10': + optional: true + + '@biomejs/cli-linux-arm64@2.4.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.10': + optional: true + + '@biomejs/cli-linux-x64@2.4.10': + optional: true + + '@biomejs/cli-win32-arm64@2.4.10': + optional: true + + '@biomejs/cli-win32-x64@2.4.10': + optional: true + + '@commitlint/cli@20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@25.6.0)(typescript@6.0.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.7.4 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@25.6.0)(typescript@6.0.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) + es-toolkit: 1.46.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.2 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.7.4 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@dicebear/adventurer-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/adventurer@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/avataaars-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/avataaars@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/big-ears-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/big-ears@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/big-smile@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/bottts-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/bottts@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/collection@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/adventurer': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/adventurer-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/avataaars': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/avataaars-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/big-ears': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/big-ears-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/big-smile': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/bottts': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/bottts-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/core': 9.4.2 + '@dicebear/croodles': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/croodles-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/dylan': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/fun-emoji': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/glass': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/icons': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/identicon': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/initials': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/lorelei': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/lorelei-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/micah': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/miniavs': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/notionists': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/notionists-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/open-peeps': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/personas': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/pixel-art': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/pixel-art-neutral': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/rings': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/shapes': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/thumbs': 9.4.2(@dicebear/core@9.4.2) + '@dicebear/toon-head': 9.4.2(@dicebear/core@9.4.2) + + '@dicebear/core@9.4.2': + dependencies: + '@types/json-schema': 7.0.15 + + '@dicebear/croodles-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/croodles@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/dylan@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/fun-emoji@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/glass@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/icons@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/identicon@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/initials@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/lorelei-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/lorelei@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/micah@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/miniavs@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/notionists-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/notionists@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/open-peeps@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/personas@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/pixel-art-neutral@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/pixel-art@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/rings@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/shapes@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/thumbs@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/toon-head@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@hono/node-server@1.19.11(hono@4.12.18)': + dependencies: + hono: 4.12.18 + + '@kurkle/color@0.3.4': {} + + '@pinojs/redact@0.4.0': {} + + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.20.0 + pg: 8.20.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.8.0': {} + + '@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 + optionalDependencies: + prisma: 7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + typescript: 6.0.3 + + '@prisma/config@7.8.0': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.8.0': {} + + '@prisma/dev@0.24.3(typescript@6.0.3)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.18) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.18 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@6.0.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + + '@prisma/engines@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/fetch-engine': 7.8.0 + '@prisma/get-platform': 7.8.0 + + '@prisma/fetch-engine@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/get-platform': 7.8.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.20.0 + better-result: 2.9.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': 19.2.14 + chart.js: 4.5.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 25.6.0 + + '@types/bun@1.3.13': + dependencies: + bun-types: 1.3.13 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.6.0 + + '@types/ms@2.1.0': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.6.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260506.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260506.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260506.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260506.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260506.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260506.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260506.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260506.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260506.1 + + abstract-logging@2.0.1: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-ify@1.0.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + aws-ssl-profiles@1.1.2: {} + + base64-js@1.5.1: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + better-result@2.9.2: {} + + bignumber.js@9.3.1: {} + + buffer-equal-constant-time@1.0.1: {} + + bun-types@1.3.13: + dependencies: + '@types/node': 25.6.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + confbox@0.2.4: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + cookie@1.1.1: {} + + cosmiconfig-typescript-loader@6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): + dependencies: + '@types/node': 25.6.0 + cosmiconfig: 9.0.1(typescript@6.0.3) + jiti: 2.6.1 + typescript: 6.0.3 + + cosmiconfig@9.0.1(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + denque@2.1.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + emoji-regex@8.0.0: {} + + empathic@2.0.0: {} + + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-toolkit@1.46.1: {} + + escalade@3.2.0: {} + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.2: {} + + fastify-plugin@5.1.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + function-bind@1.1.2: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-port-please@3.2.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + giget@3.2.0: {} + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + googleapis-common@8.0.1: + dependencies: + extend: 3.0.2 + gaxios: 7.1.4 + google-auth-library: 10.6.2 + qs: 6.15.1 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@171.4.0: + dependencies: + google-auth-library: 10.6.2 + googleapis-common: 8.0.1 + transitivePeerDependencies: + - supports-color + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hono@4.12.18: {} + + http-status-codes@2.3.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + i18next-fs-backend@2.6.5: {} + + i18next-http-middleware@3.9.6: {} + + i18next@26.0.9(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + ini@6.0.0: {} + + install@0.13.0: {} + + ipaddr.js@2.4.0: {} + + is-arrayish@0.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-obj@2.0.0: {} + + is-plain-obj@4.1.0: {} + + is-property@1.0.2: {} + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + lefthook-darwin-arm64@2.1.6: + optional: true + + lefthook-darwin-x64@2.1.6: + optional: true + + lefthook-freebsd-arm64@2.1.6: + optional: true + + lefthook-freebsd-x64@2.1.6: + optional: true + + lefthook-linux-arm64@2.1.6: + optional: true + + lefthook-linux-x64@2.1.6: + optional: true + + lefthook-openbsd-arm64@2.1.6: + optional: true + + lefthook-openbsd-x64@2.1.6: + optional: true + + lefthook-windows-arm64@2.1.6: + optional: true + + lefthook-windows-x64@2.1.6: + optional: true + + lefthook@2.1.6: + optionalDependencies: + lefthook-darwin-arm64: 2.1.6 + lefthook-darwin-x64: 2.1.6 + lefthook-freebsd-arm64: 2.1.6 + lefthook-freebsd-x64: 2.1.6 + lefthook-linux-arm64: 2.1.6 + lefthook-linux-x64: 2.1.6 + lefthook-openbsd-arm64: 2.1.6 + lefthook-openbsd-x64: 2.1.6 + lefthook-windows-arm64: 2.1.6 + lefthook-windows-x64: 2.1.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lines-and-columns@1.2.4: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + long@5.3.2: {} + + lru.min@1.1.4: {} + + math-intrinsics@1.1.0: {} + + meow@13.2.0: {} + + minimist@1.2.8: {} + + ms@2.1.3: {} + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + node-addon-api@8.7.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: {} + + object-inspect@1.13.4: {} + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): + dependencies: + '@prisma/config': 7.8.0 + '@prisma/dev': 0.24.3(typescript@6.0.3) + '@prisma/engines': 7.8.0 + '@prisma/studio-core': 0.27.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + pure-rand@6.1.0: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + quick-format-unescaped@4.0.4: {} + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + remeda@2.33.4: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + ret@0.5.0: {} + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.4: {} + + seq-queue@0.0.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + sqlstring@2.3.3: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + tinyexec@1.1.2: {} + + toad-cache@3.7.0: {} + + typescript@6.0.3: {} + + undici-types@7.19.2: {} + + url-template@2.0.8: {} + + valibot@1.2.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + + zod@4.4.3: {} From 06984d1c7c7085b38f10e089167a2a8811494541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Thu, 7 May 2026 23:16:06 -0300 Subject: [PATCH 13/15] feat(oauth): exception handling & prisma transactions --- .../controllers/discord-oauth.controller.ts | 330 +++++++++--------- .../controllers/github-oauth.controller.ts | 321 +++++++++-------- .../controllers/google-oauth.controller.ts | 315 ++++++++--------- 3 files changed, 467 insertions(+), 499 deletions(-) diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 8119c82..d9745b6 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -1,7 +1,5 @@ import crypto from "node:crypto"; -import { notionistsNeutral } from "@dicebear/collection"; -import { createAvatar } from "@dicebear/core"; import { OAuthProvider } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; @@ -83,15 +81,14 @@ const DiscordOauthController = { } const userData = await userResponse.json(); - const discordUser = DiscordUserSchema.parse(userData); + const { success, data: discordUser } = + DiscordUserSchema.safeParse(userData); - if (!discordUser.id) { + if (!success || !discordUser.id) { req.log.warn("Discord user ID not found"); return reply.status(400).send("Discord user ID not found"); } - console.debug("DISCORD_USER_INFO", userData); - const discordEmail = discordUser.email || `${discordUser.username}+${discordUser.id}@discord.oauth.local`; @@ -100,188 +97,179 @@ const DiscordOauthController = { ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` : null; - const oauthAccount = await prisma.oAuthAccount.upsert({ - where: { - provider_user_id_idx: { - provider: OAuthProvider.DISCORD, - provider_user_id: discordUser.id, - }, - }, - update: { - provider_username: discordUser.username, - provider_avatar_url: discordUser.avatar - ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` - : null, - provider_metadata: { - email: discordEmail, - lng: discordUser.locale, - discriminator: discordUser.discriminator, - }, - }, - create: { - provider: OAuthProvider.DISCORD, - user_id: null, // Will be linked to a user account later - provider_user_id: discordUser.id, - provider_username: discordUser.username, - provider_avatar_url: discordAvatar, - provider_metadata: { - email: discordEmail, - locale: discordUser.locale, - discriminator: discordUser.discriminator, - }, - }, - }); - - if (!discordAvatar) { - // Create a random Notionish avatar if Discord doesn't provide one (which is rare, but just in case) - const avatar = createAvatar(notionistsNeutral, { - seed: discordUser.id, - size: 128, - }); - - discordUser.avatar = avatar.toString(); - } - - const user = await prisma.user.upsert({ - where: { - email: discordEmail, - }, - update: { - avatar: { - upsert: { - update: { - original_url: discordAvatar as string, - mime_type: "image/jpeg", // Assuming JPEG, can be updated later if needed - file_size: 0, // We don't have this info from Discord, can be updated later if needed - height_original: 0, // We don't have this info from Discord, can be updated later if needed - width_original: 0, // We don't have this info from Discord, can be updated later if needed + try { + const { user } = await prisma.$transaction(async (tx) => { + const oauthAccount = await tx.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.DISCORD, + provider_user_id: discordUser.id, }, - create: { - original_url: discordAvatar as string, - mime_type: "image/jpeg", - file_size: 0, - height_original: 0, - width_original: 0, + }, + update: { + provider_username: discordUser.username, + provider_avatar_url: discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null, + provider_metadata: { + email: discordEmail, + lng: discordUser.locale, + discriminator: discordUser.discriminator, }, }, - }, - }, - create: { - email: discordEmail, - nickname: discordUser.username, - avatar: { create: { - original_url: discordAvatar as string, - mime_type: "image/jpeg", - file_size: 0, - height_original: 0, - width_original: 0, + provider: OAuthProvider.DISCORD, + user_id: null, // Will be linked to a user account later + provider_user_id: discordUser.id, + provider_username: discordUser.username, + provider_avatar_url: discordAvatar, + provider_metadata: { + email: discordEmail, + locale: discordUser.locale, + discriminator: discordUser.discriminator, + }, }, - }, - }, - select: { - id: true, - email: true, - nickname: true, - password: false, - avatar: true, - }, - }); - - // Not need to wait for this, if it fails we can just log it and move on - prisma.oAuthAccount - .update({ - where: { - id: oauthAccount.id, - }, - data: { - user_id: user.id, - }, - }) - .catch((e) => { - req.log.warn( - "Failed to link OAuth account to user: " + - JSON.stringify( - { - userId: user.id, - oauthAccountId: oauthAccount.id, - error: (e as Error).message, + }); + + const user = await tx.user.upsert({ + where: { email: discordEmail }, + update: { + avatar: { + upsert: { + update: { + original_url: discordAvatar as string, + mime_type: "image/png", + file_size: 0, + height_original: 0, + width_original: 0, + }, + create: { + original_url: discordAvatar as string, + mime_type: "image/png", + file_size: 0, + height_original: 0, + width_original: 0, + }, }, - null, - 2, - ), - ); + }, + }, + create: { + email: discordEmail, + nickname: discordUser.username, + avatar: { + create: { + original_url: discordAvatar as string, + mime_type: "image/png", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + avatar: true, + }, + }); + + await tx.oAuthAccount.update({ + where: { id: oauthAccount.id }, + data: { user_id: user.id }, + }); + + return { user, oauthAccount }; }); - const { refreshId, ...jwtTokens } = createTokens( - { + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { id: user.id, email: user.email, + name: user.nickname, nickname: user.nickname, - }, - 1, - ); - - const session: Session = { - id: user.id, - email: user.email, - name: user.nickname, - nickname: user.nickname, - // Hardware related info - ipv4: req.ip as Session["ipv4"], - userAgent: req.headers["user-agent"] || "unknown", - // Token related info - v: 1, - refreshId, - provider: { - type: OAuthProvider.DISCORD, - accessToken: tokens.access_token || undefined, - refreshToken: tokens.refresh_token || undefined, - }, - }; + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.DISCORD, + accessToken: tokens.access_token || undefined, + refreshToken: tokens.refresh_token || undefined, + }, + }; - await redis.set( - `session:${user.id}`, - JSON.stringify(session), - "EX", - 60 * 60 * 24 * 7, // 7 days - ); + await redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); - const isDev = env("NODE_ENV") === "development"; - const isProd = env("NODE_ENV") === "production"; - if (!isDev && !isProd) { - throw new Error( - `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + const isDev = env("NODE_ENV") === "development"; + const isProd = env("NODE_ENV") === "production"; + if (!isDev && !isProd) { + throw new Error( + `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + ); + } + + const secure = isProd; + reply + .setCookie("dojoh.access_token", jwtTokens.accessToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24, // 1 day + }) + .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + }) + .setCookie("dojoh.session", JSON.stringify(user), { + path: "/", + httpOnly: false, // accessible from frontend if needed + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); + + return reply.redirect( + env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), + ); + } catch (error) { + req.log.error( + `Error during Discord OAuth callback: ${JSON.stringify(error, null, 2)}`, ); - } - const secure = isProd; - reply - .setCookie("dojoh.access_token", jwtTokens.accessToken, { - path: "/", - httpOnly: true, - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24, // 1 day - }) - .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { - path: "/", - httpOnly: true, - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 7 days - }) - .setCookie("dojoh.session", JSON.stringify(user), { - path: "/", - httpOnly: false, // accessible from frontend if needed - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, + const queryParams = new URLSearchParams({ + error: "oauth_failed", + error_code: (0xdeadbeef).toString(), + provider: "discord", + reason: "Unexpected error", }); + const url = new URL( + `?${queryParams.toString()}`, + env("POST_FAILURE_REDIRECT_URI", "http://127.0.0.1:3000/"), + ); - return reply.redirect( - env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), - ); + return reply.redirect(url.toString()); + } }, async redirect(_: FastifyRequest, reply: FastifyReply) { const state = crypto.randomBytes(32).toString("hex"); diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index e3053e7..b396386 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -1,7 +1,5 @@ import crypto from "node:crypto"; -import { notionistsNeutral } from "@dicebear/collection"; -import { createAvatar } from "@dicebear/core"; import { OAuthProvider } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; @@ -93,9 +91,9 @@ const GithubOauthController = { } const userData = await userResponse.json(); - const githubUser = GithubUserSchema.parse(userData); + const { success, data: githubUser } = GithubUserSchema.safeParse(userData); - if (!githubUser.id) { + if (!success || !githubUser.id) { req.log.warn("Github user ID not found"); return reply.status(400).send("Github user ID not found"); } @@ -104,184 +102,177 @@ const GithubOauthController = { githubUser.email || `${githubUser.login}+${githubUser.id}@github.oauth.local`; - const oauthAccount = await prisma.oAuthAccount.upsert({ - where: { - provider_user_id_idx: { - provider: OAuthProvider.GITHUB, - provider_user_id: String(githubUser.id), - }, - }, - update: { - provider_username: githubUser.name, - provider_avatar_url: githubUser.avatar_url || null, - provider_metadata: { - email: githubEmail, - name: githubUser.name, - }, - }, - create: { - provider: OAuthProvider.GITHUB, - user_id: null, // Will be linked to a user account later - provider_user_id: String(githubUser.id), - provider_username: githubUser.login, - provider_avatar_url: githubUser.avatar_url, - provider_metadata: { - email: githubEmail, - name: githubUser.name, - }, - }, - }); - - if (!githubUser.avatar_url) { - // Create a random Notionish avatar if GitHub doesn't provide one (which is rare, but just in case) - const avatar = createAvatar(notionistsNeutral, { - seed: String(githubUser.id), - size: 128, - }); - - githubUser.avatar_url = avatar.toString(); - } - - const user = await prisma.user.upsert({ - where: githubEmail - ? { email: githubEmail } - : { nickname: githubUser.login }, - update: { - nickname: githubUser.login, - avatar: { - upsert: { - update: { - original_url: githubUser.avatar_url as string, - mime_type: "image/jpeg", // Assuming JPEG, can be updated later if needed - file_size: 0, // We don't have this info from GitHub, can be updated later if needed - height_original: 0, // We don't have this info from GitHub, can be updated later if needed - width_original: 0, // We don't have this info from GitHub, can be updated later if needed + try { + const { user } = await prisma.$transaction(async (tx) => { + const oauthAccount = await tx.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GITHUB, + provider_user_id: String(githubUser.id), }, - create: { - original_url: githubUser.avatar_url as string, - mime_type: "image/jpeg", - file_size: 0, - height_original: 0, - width_original: 0, + }, + update: { + provider_username: githubUser.name, + provider_avatar_url: githubUser.avatar_url || null, + provider_metadata: { + email: githubEmail, + name: githubUser.name, }, }, - }, - }, - create: { - email: githubEmail, - nickname: githubUser.login, - avatar: { create: { - original_url: githubUser.avatar_url as string, - mime_type: "image/jpeg", - file_size: 0, - height_original: 0, - width_original: 0, + provider: OAuthProvider.GITHUB, + user_id: null, // Will be linked to a user account later + provider_user_id: String(githubUser.id), + provider_username: githubUser.login, + provider_avatar_url: githubUser.avatar_url, + provider_metadata: { + email: githubEmail, + name: githubUser.name, + }, }, - }, - }, - select: { - id: true, - email: true, - nickname: true, - password: false, - avatar: true, - }, - }); - - // Not need to wait for this, if it fails we can just log it and move on - prisma.oAuthAccount - .update({ - where: { - id: oauthAccount.id, - }, - data: { - user_id: user.id, - }, - }) - .catch((e) => { - req.log.warn( - "Failed to link OAuth account to user: " + - JSON.stringify( - { - userId: user.id, - oauthAccountId: oauthAccount.id, - error: (e as Error).message, + }); + + const user = await tx.user.upsert({ + where: githubEmail + ? { email: githubEmail } + : { nickname: githubUser.login }, + update: { + nickname: githubUser.login, + avatar: { + upsert: { + update: { + original_url: githubUser.avatar_url as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + create: { + original_url: githubUser.avatar_url as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, }, - null, - 2, - ), - ); + }, + }, + create: { + email: githubEmail, + nickname: githubUser.login, + avatar: { + create: { + original_url: githubUser.avatar_url as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + avatar: true, + }, + }); + + await tx.oAuthAccount.update({ + where: { id: oauthAccount.id }, + data: { user_id: user.id }, + }); + + return { user, oauthAccount }; }); - const { refreshId, ...jwtTokens } = createTokens( - { + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { id: user.id, email: user.email, + name: user.nickname, nickname: user.nickname, - }, - 1, - ); - - const session: Session = { - id: user.id, - email: user.email, - name: user.nickname, - nickname: user.nickname, - // Hardware related info - ipv4: req.ip as Session["ipv4"], - userAgent: req.headers["user-agent"] || "unknown", - // Token related info - v: 1, - refreshId, - provider: { - type: OAuthProvider.GITHUB, - accessToken: tokens.access_token || undefined, - }, - }; + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GITHUB, + accessToken: tokens.access_token || undefined, + }, + }; - await redis.set( - `session:${user.id}`, - JSON.stringify(session), - "EX", - 60 * 60 * 24 * 7, // 7 days - ); + await redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); - const isDev = env("NODE_ENV") === "development"; - const isProd = env("NODE_ENV") === "production"; - if (!isDev && !isProd) { - throw new Error( - `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + const isDev = env("NODE_ENV") === "development"; + const isProd = env("NODE_ENV") === "production"; + if (!isDev && !isProd) { + throw new Error( + `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + ); + } + + const secure = isProd; + reply + .setCookie("dojoh.access_token", jwtTokens.accessToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24, // 1 day + }) + .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + }) + .setCookie("dojoh.session", JSON.stringify(user), { + path: "/", + httpOnly: false, // accessible from frontend if needed + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); + + return reply.redirect( + env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), + ); + } catch (error) { + req.log.error( + `Error during Github OAuth callback: ${JSON.stringify(error, null, 2)}`, ); - } - const secure = isProd; - reply - .setCookie("dojoh.access_token", jwtTokens.accessToken, { - path: "/", - httpOnly: true, - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24, // 1 day - }) - .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { - path: "/", - httpOnly: true, - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 7 days - }) - .setCookie("dojoh.session", JSON.stringify(user), { - path: "/", - httpOnly: false, // accessible from frontend if needed - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, + const queryParams = new URLSearchParams({ + error: "oauth_failed", + error_code: (0xdeadbeef).toString(), + provider: "github", + reason: "Unexpected error", }); + const url = new URL( + `?${queryParams.toString()}`, + env("POST_FAILURE_REDIRECT_URI", "http://127.0.0.1:3000/"), + ); - return reply.redirect( - env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), - ); + return reply.redirect(url.toString()); + } }, async redirect(_: FastifyRequest, reply: FastifyReply) { const verifier = generateCodeVerifier(); diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index cea950b..d715d97 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -1,7 +1,5 @@ import crypto from "node:crypto"; -import { notionistsNeutral } from "@dicebear/collection"; -import { createAvatar } from "@dicebear/core"; import { OAuthProvider } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; import { google } from "googleapis"; @@ -54,9 +52,9 @@ const GoogleOauthController = { }); const { data } = await oauth2.userinfo.get(); - const googleUser = GoogleUserSchema.parse(data); + const { success, data: googleUser } = GoogleUserSchema.safeParse(data); - if (!googleUser.id) { + if (!success || !googleUser.id) { req.log.warn("Google user ID not found"); return reply.status(400).send("Google user ID not found"); } @@ -69,184 +67,175 @@ const GoogleOauthController = { googleUser.email || `${googleUsername}+${googleUser.id}@google.oauth.local`; - const oauthAccount = await prisma.oAuthAccount.upsert({ - where: { - provider_user_id_idx: { - provider: OAuthProvider.GOOGLE, - provider_user_id: googleUser.id, - }, - }, - update: { - provider_username: googleUsername, - provider_avatar_url: googleUser.picture, - provider_metadata: { - email: googleEmail, - }, - }, - create: { - provider: OAuthProvider.GOOGLE, - user_id: null, // Will be linked to a user account later - provider_user_id: googleUser.id, - provider_username: googleUser.name, - provider_avatar_url: googleUser.picture, - provider_metadata: { - name: googleUser.name, - email: googleEmail, - }, - }, - }); - - if (!googleUser.picture) { - // Create a random Notionish avatar if Google doesn't provide one (which is rare, but just in case) - const avatar = createAvatar(notionistsNeutral, { - seed: googleUser.id, - size: 128, - }); - - googleUser.picture = avatar.toString(); - } - - const user = await prisma.user.upsert({ - where: { - email: googleEmail, - }, - update: { - nickname: googleUsername, - avatar: { - upsert: { - update: { - original_url: googleUser.picture as string, - mime_type: "image/jpeg", // Assuming JPEG, can be updated later if needed - file_size: 0, // We don't have this info from Google, can be updated later if needed - height_original: 0, // We don't have this info from Google, can be updated later if needed - width_original: 0, // We don't have this info from Google, can be updated later if needed + try { + const { user } = await prisma.$transaction(async (tx) => { + const oauthAccount = await tx.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: googleUser.id, }, - create: { - original_url: googleUser.picture as string, - mime_type: "image/jpeg", - file_size: 0, - height_original: 0, - width_original: 0, + }, + update: { + provider_username: googleUsername, + provider_avatar_url: googleUser.picture, + provider_metadata: { + email: googleEmail, }, }, - }, - }, - create: { - email: googleEmail, - nickname: googleUsername, - avatar: { create: { - original_url: googleUser.picture as string, - mime_type: "image/jpeg", - file_size: 0, - height_original: 0, - width_original: 0, + provider: OAuthProvider.GOOGLE, + user_id: null, // Will be linked to a user account later + provider_user_id: googleUser.id, + provider_username: googleUser.name, + provider_avatar_url: googleUser.picture, + provider_metadata: { + name: googleUser.name, + email: googleEmail, + }, }, - }, - }, - select: { - id: true, - email: true, - nickname: true, - password: false, - avatar: true, - }, - }); + }); - // Not need to wait for this, if it fails we can just log it and move on - prisma.oAuthAccount - .update({ - where: { - id: oauthAccount.id, - }, - data: { - user_id: user.id, - }, - }) - .catch((e) => { - req.log.warn( - "Failed to link OAuth account to user: " + - JSON.stringify( - { - userId: user.id, - oauthAccountId: oauthAccount.id, - error: (e as Error).message, + const user = await tx.user.upsert({ + where: { email: googleEmail }, + update: { + nickname: googleUsername, + avatar: { + upsert: { + update: { + original_url: googleUser.picture as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + create: { + original_url: googleUser.picture as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, }, - null, - 2, - ), - ); + }, + }, + create: { + email: googleEmail, + nickname: googleUsername, + avatar: { + create: { + original_url: googleUser.picture as string, + mime_type: "image/jpeg", + file_size: 0, + height_original: 0, + width_original: 0, + }, + }, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + avatar: true, + }, + }); + + await tx.oAuthAccount.update({ + where: { id: oauthAccount.id }, + data: { user_id: user.id }, + }); + + return { user, oauthAccount }; }); - const { refreshId, ...jwtTokens } = createTokens( - { + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { id: user.id, email: user.email, + name: user.nickname, nickname: user.nickname, - }, - 1, - ); + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GOOGLE, + accessToken: tokens.access_token || undefined, + refreshToken: tokens.refresh_token || undefined, + }, + }; - const session: Session = { - id: user.id, - email: user.email, - name: user.nickname, - nickname: user.nickname, - // Hardware related info - ipv4: req.ip as Session["ipv4"], - userAgent: req.headers["user-agent"] || "unknown", - // Token related info - v: 1, - refreshId, - provider: { - type: OAuthProvider.GOOGLE, - accessToken: tokens.access_token || undefined, - refreshToken: tokens.refresh_token || undefined, - }, - }; + await redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + const isDev = env("NODE_ENV") === "development"; + const isProd = env("NODE_ENV") === "production"; + if (!isDev && !isProd) { + throw new Error( + `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + ); + } - await redis.set( - `session:${user.id}`, - JSON.stringify(session), - "EX", - 60 * 60 * 24 * 7, // 7 days - ); + const secure = isProd; + reply + .setCookie("dojoh.access_token", jwtTokens.accessToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24, // 1 day + }) + .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { + path: "/", + httpOnly: true, + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + }) + .setCookie("dojoh.session", JSON.stringify(user), { + path: "/", + httpOnly: false, // accessible from frontend if needed + secure, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }); - const isDev = env("NODE_ENV") === "development"; - const isProd = env("NODE_ENV") === "production"; - if (!isDev && !isProd) { - throw new Error( - `Unknown NODE_ENV value: ${env("NODE_ENV")}. Expected "development" or "production".`, + return reply.redirect( + env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), + ); + } catch (error) { + req.log.error( + `Error during Google OAuth callback: ${JSON.stringify(error, null, 2)}`, ); - } - const secure = isProd; - reply - .setCookie("dojoh.access_token", jwtTokens.accessToken, { - path: "/", - httpOnly: true, - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24, // 1 day - }) - .setCookie("dojoh.refresh_token", jwtTokens.refreshToken, { - path: "/", - httpOnly: true, - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 7 days - }) - .setCookie("dojoh.session", JSON.stringify(user), { - path: "/", - httpOnly: false, // accessible from frontend if needed - secure, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, + const queryParams = new URLSearchParams({ + error: "oauth_failed", + error_code: (0xdeadbeef).toString(), + provider: "google", + reason: "Unexpected error", }); + const url = new URL( + `?${queryParams.toString()}`, + env("POST_FAILURE_REDIRECT_URI", "http://127.0.0.1:3000/"), + ); - return reply.redirect( - env("POST_AUTH_REDIRECT_URI", "http://127.0.0.1:3000/-/home"), - ); + return reply.redirect(url.toString()); + } }, async redirect(_req: FastifyRequest, reply: FastifyReply) { const scopes: Array = ["openid", "profile", "email"]; From 5f26ddd31d63f71f8ab18dce43d63dc3ae090411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Fri, 8 May 2026 15:39:59 -0300 Subject: [PATCH 14/15] ci(bun): update bun.lock --- bun.lock | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/bun.lock b/bun.lock index 8890a4c..2e797aa 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "dojoh-dev", "dependencies": { + "@dicebear/collection": "^9.4.2", + "@dicebear/core": "^9.4.2", "@fastify/cookie": "^11.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -91,6 +93,72 @@ "@conventional-changelog/git-client": ["@conventional-changelog/git-client@2.7.0", "", { "dependencies": { "@simple-libs/child-process-utils": "^1.0.0", "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.4.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw=="], + "@dicebear/adventurer": ["@dicebear/adventurer@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig=="], + + "@dicebear/adventurer-neutral": ["@dicebear/adventurer-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-5xgkG/mNL4j3Q4SJGQLBU/KnU90tng8Ze5ofThD+55wi0oeY/nSAUowg6UFCmHrktjifj/MEx3CQqbpcPWtfIA=="], + + "@dicebear/avataaars": ["@dicebear/avataaars@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw=="], + + "@dicebear/avataaars-neutral": ["@dicebear/avataaars-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-/eNrp0YCNJRwQXqOloLm1+3Ss2C+pMpUQIGkbEnGsP1UK+13Ge80ggDDof1HpdqvG9HAZcKa7hnbG/0HSwyDSw=="], + + "@dicebear/big-ears": ["@dicebear/big-ears@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w=="], + + "@dicebear/big-ears-neutral": ["@dicebear/big-ears-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-M8Ozmzza4eY4hpLOYULgJxMYmBA0CsBnrE15/xw6LZkEREXnrX5z0NJsf8hUfdyF6BWZ+RBgzoiav32DAC5zcg=="], + + "@dicebear/big-smile": ["@dicebear/big-smile@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ=="], + + "@dicebear/bottts": ["@dicebear/bottts@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ=="], + + "@dicebear/bottts-neutral": ["@dicebear/bottts-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA=="], + + "@dicebear/collection": ["@dicebear/collection@9.4.2", "", { "dependencies": { "@dicebear/adventurer": "9.4.2", "@dicebear/adventurer-neutral": "9.4.2", "@dicebear/avataaars": "9.4.2", "@dicebear/avataaars-neutral": "9.4.2", "@dicebear/big-ears": "9.4.2", "@dicebear/big-ears-neutral": "9.4.2", "@dicebear/big-smile": "9.4.2", "@dicebear/bottts": "9.4.2", "@dicebear/bottts-neutral": "9.4.2", "@dicebear/croodles": "9.4.2", "@dicebear/croodles-neutral": "9.4.2", "@dicebear/dylan": "9.4.2", "@dicebear/fun-emoji": "9.4.2", "@dicebear/glass": "9.4.2", "@dicebear/icons": "9.4.2", "@dicebear/identicon": "9.4.2", "@dicebear/initials": "9.4.2", "@dicebear/lorelei": "9.4.2", "@dicebear/lorelei-neutral": "9.4.2", "@dicebear/micah": "9.4.2", "@dicebear/miniavs": "9.4.2", "@dicebear/notionists": "9.4.2", "@dicebear/notionists-neutral": "9.4.2", "@dicebear/open-peeps": "9.4.2", "@dicebear/personas": "9.4.2", "@dicebear/pixel-art": "9.4.2", "@dicebear/pixel-art-neutral": "9.4.2", "@dicebear/rings": "9.4.2", "@dicebear/shapes": "9.4.2", "@dicebear/thumbs": "9.4.2", "@dicebear/toon-head": "9.4.2" }, "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-KArubv7if8H7j9sIfpDK2hJJqrdNVR5zMPAMOSpIU2JPyXx8TC9o5wsmXb8il5wOHgaS9Q/cla7jUNIiDD7Gsg=="], + + "@dicebear/core": ["@dicebear/core@9.4.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w=="], + + "@dicebear/croodles": ["@dicebear/croodles@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw=="], + + "@dicebear/croodles-neutral": ["@dicebear/croodles-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-oG5IeUdtiYshQ89gkAVcl5w3xAEi5UZX2fTzIyelpBPCG176l7VuuFzlxi2umnB3E6LVHYy06DXvUo/p+rXB2Q=="], + + "@dicebear/dylan": ["@dicebear/dylan@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-1vQvRu9x9DrwFxhFaIU2rf0EUL04yDTbAt7fHyAjM0mEsKzTD4mRNf95tCRuavCoW6W48u7A/OY6jyIub6kxLQ=="], + + "@dicebear/fun-emoji": ["@dicebear/fun-emoji@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg=="], + + "@dicebear/glass": ["@dicebear/glass@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-z5qUogHQ1b6UJ2zCqT848mU2U9DKbVDhiX6GPDjD7tYLisCCJVisH9p6WyNdHvflUd4SHkA6gRqVJIh2v2HnTA=="], + + "@dicebear/icons": ["@dicebear/icons@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-QSMMz0NA03ypSGhXC8HQX8FSj8lYT+/5yqH+/N03OH2IjL0q7wwGZ7nqsrtlRp76O5WqMTwGfSbTUUYPjFr+Xw=="], + + "@dicebear/identicon": ["@dicebear/identicon@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww=="], + + "@dicebear/initials": ["@dicebear/initials@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw=="], + + "@dicebear/lorelei": ["@dicebear/lorelei@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA=="], + + "@dicebear/lorelei-neutral": ["@dicebear/lorelei-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-yspanTthA5vh6iCdeLzn6xZ4yYMYRcfcxblcgSvHTF1ut0bjAXtw5SXzZ6aJTrJWiHkzYOQuTOR6GVYiW80Q7w=="], + + "@dicebear/micah": ["@dicebear/micah@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g=="], + + "@dicebear/miniavs": ["@dicebear/miniavs@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-wLwyFNNUnDRd3BbhSBhXR0XEpX8sG0/xDA5M/OkDoapLqZnnI48YLUSDd2N5QTAVMmcSEuZOYxkcnj7WW79vlg=="], + + "@dicebear/notionists": ["@dicebear/notionists@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA=="], + + "@dicebear/notionists-neutral": ["@dicebear/notionists-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-AyD9kEfVxQUwDGf4Op059gVmYIOAkTKg3dtE9h9mEKP7zl/kMy5B67BFFOo7sB0mXCjzAegZ6ekGU02E8+hIHw=="], + + "@dicebear/open-peeps": ["@dicebear/open-peeps@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-i01tLgtp2g937T81sVeAOVlqsCtiTck/Kw20g7hN80+7xrXjOUepz2HPLy3HeiMjwjMGRy5o54kSd0/8Ht4Dqg=="], + + "@dicebear/personas": ["@dicebear/personas@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-NJlkvI5F5gugt6t2+7QrYNTwQC7+4IQZS3vG0dYk2BncxOHax0BuLovdSdiAesTL4ZkytFYIydWmKmV2/xcUwg=="], + + "@dicebear/pixel-art": ["@dicebear/pixel-art@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg=="], + + "@dicebear/pixel-art-neutral": ["@dicebear/pixel-art-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-9e9Lz554uQvWaXV2P17ss+hPa6rTyuAKBtB8zk8ECjHiZzIl61N/KcTVLZ4dILVZwj7gYriaLo16QEqvL2GJCg=="], + + "@dicebear/rings": ["@dicebear/rings@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g=="], + + "@dicebear/shapes": ["@dicebear/shapes@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ=="], + + "@dicebear/thumbs": ["@dicebear/thumbs@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q=="], + + "@dicebear/toon-head": ["@dicebear/toon-head@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-lwFeSXyAnaKnCfMt9TiJwnD1cXQUGkey/0h6i/+4TVHVMCz5/Ri5u1ynovPNHy1SnBf858QwoXHkxilGLwQX/g=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.4.1", "", {}, "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q=="], "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw=="], @@ -171,6 +239,8 @@ "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], From 95060eb30fb8ddb2b16493f4dddc075d3e3d09bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Fri, 8 May 2026 20:37:19 -0300 Subject: [PATCH 15/15] fix(plugins/cors): regex/wildcard support --- plugins/cors.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/plugins/cors.ts b/plugins/cors.ts index 7c45e0b..3056d14 100644 --- a/plugins/cors.ts +++ b/plugins/cors.ts @@ -2,9 +2,16 @@ import fp from "fastify-plugin"; import env from "@/config/env"; -const allowedDomains = { +type NODE_ENVIRONMENT = "development" | "production"; + +const allowedDomains: Record = { development: ["http://127.0.0.1:3000", "http://127.0.0.1:8080"], - production: ["https://dojoh.dev", "https://www.dojoh.dev"], + production: [ + // Matches any subdomain (letters, numbers, hyphens) of ".a.run.app" over HTTPS, e.g. https://my-app.a.run.app + /^https:\/\/[a-z0-9-]+\.a\.run\.app$/, + "https://dojoh.dev", + "https://www.dojoh.dev", + ], }; export default fp((f) => { @@ -13,7 +20,17 @@ export default fp((f) => { const envName = env("NODE_ENV", "development"); // Allow browser origins - if (origin && allowedDomains[envName].includes(origin)) { + const allowed = + origin && + allowedDomains[envName].some((entry) => { + if (entry instanceof RegExp) { + return entry.test(origin); + } + + return entry === origin; + }); + + if (allowed) { reply.header("Access-Control-Allow-Origin", origin); }