From 8eb7c90958ae7922dec2d13f2ea7d9c3b308d892 Mon Sep 17 00:00:00 2001 From: hibna Date: Sat, 21 Feb 2026 13:22:51 +0300 Subject: [PATCH] chore: update gitignore for phase02 --- .gitignore | 1 + apps/api/package.json | 5 +- apps/api/src/index.ts | 43 ++++- apps/api/src/lib/errors.ts | 30 +++ apps/api/src/lib/jwt.ts | 27 +++ apps/api/src/lib/password.ts | 14 ++ apps/api/src/plugins/auth.ts | 48 +++++ apps/api/src/plugins/db.ts | 21 +++ apps/api/src/routes/auth/index.ts | 196 ++++++++++++++++++++ apps/api/src/routes/auth/schemas.ts | 16 ++ packages/database/package.json | 9 +- packages/database/src/schema/allocations.ts | 14 ++ packages/database/src/schema/index.ts | 1 + packages/database/src/schema/nodes.ts | 12 -- packages/database/src/seed.ts | 36 +++- pnpm-lock.yaml | 35 ++++ 16 files changed, 479 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/lib/errors.ts create mode 100644 apps/api/src/lib/jwt.ts create mode 100644 apps/api/src/lib/password.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/api/src/plugins/db.ts create mode 100644 apps/api/src/routes/auth/index.ts create mode 100644 apps/api/src/routes/auth/schemas.ts create mode 100644 packages/database/src/schema/allocations.ts diff --git a/.gitignore b/.gitignore index b3b5d4c..8247a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ build/ # Claude .claude/ +plans.md \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index b4cd2bb..5b6a6eb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "dotenv -e ../../.env -- tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/" @@ -18,11 +18,14 @@ "@source/database": "workspace:*", "@source/shared": "workspace:*", "argon2": "^0.41.0", + "drizzle-orm": "^0.38.0", "fastify": "^5.2.0", + "fastify-plugin": "^5.0.0", "pino-pretty": "^13.0.0", "socket.io": "^4.8.0" }, "devDependencies": { + "dotenv-cli": "^8.0.0", "tsx": "^4.19.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c2f7f29..8fff519 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,26 +1,63 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import cookie from '@fastify/cookie'; +import dbPlugin from './plugins/db.js'; +import authPlugin from './plugins/auth.js'; +import authRoutes from './routes/auth/index.js'; +import { AppError } from './lib/errors.js'; const app = Fastify({ logger: { - transport: { - target: 'pino-pretty', - }, + transport: + process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty' } + : undefined, }, }); +// Plugins await app.register(cors, { origin: process.env.CORS_ORIGIN || 'http://localhost:5173', credentials: true, }); await app.register(cookie); +await app.register(dbPlugin); +await app.register(authPlugin); +// Error handler +app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => { + if (error instanceof AppError) { + return reply.code(error.statusCode).send({ + error: error.name, + message: error.message, + code: error.code, + }); + } + + // Fastify validation errors + if (error.validation) { + return reply.code(400).send({ + error: 'Validation Error', + message: error.message, + }); + } + + app.log.error(error); + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'An unexpected error occurred', + }); +}); + +// Routes app.get('/api/health', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; }); +await app.register(authRoutes, { prefix: '/api/auth' }); + +// Start const PORT = Number(process.env.PORT) || 3000; const HOST = process.env.HOST || '0.0.0.0'; diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..1dffe08 --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,30 @@ +export class AppError extends Error { + constructor( + public statusCode: number, + message: string, + public code?: string, + ) { + super(message); + this.name = 'AppError'; + } + + static badRequest(message: string, code?: string) { + return new AppError(400, message, code); + } + + static unauthorized(message = 'Unauthorized', code?: string) { + return new AppError(401, message, code); + } + + static forbidden(message = 'Forbidden', code?: string) { + return new AppError(403, message, code); + } + + static notFound(message = 'Not found', code?: string) { + return new AppError(404, message, code); + } + + static conflict(message: string, code?: string) { + return new AppError(409, message, code); + } +} diff --git a/apps/api/src/lib/jwt.ts b/apps/api/src/lib/jwt.ts new file mode 100644 index 0000000..d25c906 --- /dev/null +++ b/apps/api/src/lib/jwt.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; + +export interface AccessTokenPayload { + sub: string; // user id + email: string; + isSuperAdmin: boolean; +} + +export interface RefreshTokenPayload { + sub: string; // user id + type: 'refresh'; +} + +const ACCESS_TOKEN_EXPIRY = '15m'; +const REFRESH_TOKEN_EXPIRY = '7d'; + +export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string { + return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY }); +} + +export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string { + return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY }); +} + +export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload { + return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload; +} diff --git a/apps/api/src/lib/password.ts b/apps/api/src/lib/password.ts new file mode 100644 index 0000000..bb986c1 --- /dev/null +++ b/apps/api/src/lib/password.ts @@ -0,0 +1,14 @@ +import argon2 from 'argon2'; + +export async function hashPassword(password: string): Promise { + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, + }); +} + +export async function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password); +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..ebabc50 --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,48 @@ +import fp from 'fastify-plugin'; +import jwt from '@fastify/jwt'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import type { AccessTokenPayload } from '../lib/jwt.js'; + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + jwtRefresh: FastifyInstance['jwt']; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: AccessTokenPayload; + user: AccessTokenPayload; + } +} + +export default fp(async (app: FastifyInstance) => { + const jwtSecret = process.env.JWT_SECRET; + const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET; + + if (!jwtSecret || !jwtRefreshSecret) { + throw new Error('JWT_SECRET and JWT_REFRESH_SECRET environment variables are required'); + } + + // Access token JWT + await app.register(jwt, { + secret: jwtSecret, + namespace: 'jwt', + }); + + // Refresh token JWT (separate namespace) + await app.register(jwt, { + secret: jwtRefreshSecret, + namespace: 'jwtRefresh', + }); + + // Auth decorator + app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => { + try { + await request.jwtVerify(); + } catch { + reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired token' }); + } + }); +}); diff --git a/apps/api/src/plugins/db.ts b/apps/api/src/plugins/db.ts new file mode 100644 index 0000000..3a00c61 --- /dev/null +++ b/apps/api/src/plugins/db.ts @@ -0,0 +1,21 @@ +import fp from 'fastify-plugin'; +import type { FastifyInstance } from 'fastify'; +import { createDb, type Database } from '@source/database'; + +declare module 'fastify' { + interface FastifyInstance { + db: Database; + } +} + +export default fp(async (app: FastifyInstance) => { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); + } + + const db = createDb(databaseUrl); + app.decorate('db', db); + + app.log.info('Database connected'); +}); diff --git a/apps/api/src/routes/auth/index.ts b/apps/api/src/routes/auth/index.ts new file mode 100644 index 0000000..5daa104 --- /dev/null +++ b/apps/api/src/routes/auth/index.ts @@ -0,0 +1,196 @@ +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { users } from '@source/database'; +import { hashPassword, verifyPassword } from '../../lib/password.js'; +import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js'; +import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js'; +import { AppError } from '../../lib/errors.js'; +import { RegisterSchema, LoginSchema } from './schemas.js'; + +const REFRESH_COOKIE_NAME = 'refresh_token'; +const REFRESH_COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/api/auth', + maxAge: 7 * 24 * 60 * 60, // 7 days in seconds +}; + +export default async function authRoutes(app: FastifyInstance) { + // POST /api/auth/register + app.post('/register', { schema: RegisterSchema }, async (request, reply) => { + const { email, username, password } = request.body as { + email: string; + username: string; + password: string; + }; + + // Check if email already exists + const existingEmail = await app.db.query.users.findFirst({ + where: eq(users.email, email), + }); + if (existingEmail) { + throw AppError.conflict('Email already in use', 'EMAIL_TAKEN'); + } + + // Check if username already exists + const existingUsername = await app.db.query.users.findFirst({ + where: eq(users.username, username), + }); + if (existingUsername) { + throw AppError.conflict('Username already in use', 'USERNAME_TAKEN'); + } + + const passwordHash = await hashPassword(password); + + const [user] = await app.db + .insert(users) + .values({ + email, + username, + passwordHash, + }) + .returning({ + id: users.id, + email: users.email, + username: users.username, + isSuperAdmin: users.isSuperAdmin, + }); + + // Generate tokens + const accessToken = signAccessToken(app, { + sub: user!.id, + email: user!.email, + isSuperAdmin: user!.isSuperAdmin, + }); + + const refreshToken = signRefreshToken(app, { + sub: user!.id, + type: 'refresh', + }); + + reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS); + + return reply.code(201).send({ + user: { + id: user!.id, + email: user!.email, + username: user!.username, + isSuperAdmin: user!.isSuperAdmin, + }, + accessToken, + }); + }); + + // POST /api/auth/login + app.post('/login', { schema: LoginSchema }, async (request, reply) => { + const { email, password } = request.body as { email: string; password: string }; + + const user = await app.db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (!user) { + throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS'); + } + + const isValid = await verifyPassword(user.passwordHash, password); + if (!isValid) { + throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS'); + } + + const accessToken = signAccessToken(app, { + sub: user.id, + email: user.email, + isSuperAdmin: user.isSuperAdmin, + }); + + const refreshToken = signRefreshToken(app, { + sub: user.id, + type: 'refresh', + }); + + reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS); + + return { + user: { + id: user.id, + email: user.email, + username: user.username, + isSuperAdmin: user.isSuperAdmin, + avatarUrl: user.avatarUrl, + }, + accessToken, + }; + }); + + // POST /api/auth/refresh + app.post('/refresh', async (request, reply) => { + const token = request.cookies[REFRESH_COOKIE_NAME]; + if (!token) { + throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN'); + } + + let payload: RefreshTokenPayload; + try { + payload = verifyRefreshToken(app, token); + } catch { + reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); + throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN'); + } + + const user = await app.db.query.users.findFirst({ + where: eq(users.id, payload.sub), + }); + + if (!user) { + reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); + throw AppError.unauthorized('User not found', 'USER_NOT_FOUND'); + } + + // Token rotation: issue new tokens + const accessToken = signAccessToken(app, { + sub: user.id, + email: user.email, + isSuperAdmin: user.isSuperAdmin, + }); + + const newRefreshToken = signRefreshToken(app, { + sub: user.id, + type: 'refresh', + }); + + reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS); + + return { accessToken }; + }); + + // POST /api/auth/logout + app.post('/logout', async (_request, reply) => { + reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); + return { success: true }; + }); + + // GET /api/auth/me + app.get('/me', { onRequest: [app.authenticate] }, async (request) => { + const payload = request.user; + + const user = await app.db.query.users.findFirst({ + where: eq(users.id, payload.sub), + columns: { + id: true, + email: true, + username: true, + isSuperAdmin: true, + avatarUrl: true, + createdAt: true, + }, + }); + + if (!user) { + throw AppError.notFound('User not found'); + } + + return { user }; + }); +} diff --git a/apps/api/src/routes/auth/schemas.ts b/apps/api/src/routes/auth/schemas.ts new file mode 100644 index 0000000..70dce25 --- /dev/null +++ b/apps/api/src/routes/auth/schemas.ts @@ -0,0 +1,16 @@ +import { Type } from '@sinclair/typebox'; + +export const RegisterSchema = { + body: Type.Object({ + email: Type.String({ format: 'email' }), + username: Type.String({ minLength: 3, maxLength: 100 }), + password: Type.String({ minLength: 8, maxLength: 128 }), + }), +}; + +export const LoginSchema = { + body: Type.Object({ + email: Type.String({ format: 'email' }), + password: Type.String(), + }), +}; diff --git a/packages/database/package.json b/packages/database/package.json index 78bf6c4..96d41e4 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -8,10 +8,10 @@ "scripts": { "build": "tsc", "lint": "eslint src/", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:seed": "tsx src/seed.ts", - "db:studio": "drizzle-kit studio" + "db:generate": "dotenv -e ../../.env -- drizzle-kit generate", + "db:migrate": "dotenv -e ../../.env -- drizzle-kit migrate", + "db:seed": "dotenv -e ../../.env -- tsx src/seed.ts", + "db:studio": "dotenv -e ../../.env -- drizzle-kit studio" }, "dependencies": { "drizzle-orm": "^0.38.0", @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "dotenv-cli": "^8.0.0", "drizzle-kit": "^0.30.0", "tsx": "^4.19.0" } diff --git a/packages/database/src/schema/allocations.ts b/packages/database/src/schema/allocations.ts new file mode 100644 index 0000000..7be47aa --- /dev/null +++ b/packages/database/src/schema/allocations.ts @@ -0,0 +1,14 @@ +import { pgTable, uuid, varchar, integer, boolean } from 'drizzle-orm/pg-core'; +import { nodes } from './nodes'; +import { servers } from './servers'; + +export const allocations = pgTable('allocations', { + id: uuid('id').defaultRandom().primaryKey(), + nodeId: uuid('node_id') + .notNull() + .references(() => nodes.id, { onDelete: 'cascade' }), + serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }), + ip: varchar('ip', { length: 45 }).notNull(), + port: integer('port').notNull(), + isDefault: boolean('is_default').default(false).notNull(), +}); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 829e392..494e9e3 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -1,6 +1,7 @@ export * from './users'; export * from './organizations'; export * from './nodes'; +export * from './allocations'; export * from './games'; export * from './servers'; export * from './backups'; diff --git a/packages/database/src/schema/nodes.ts b/packages/database/src/schema/nodes.ts index f46932c..884d6f4 100644 --- a/packages/database/src/schema/nodes.ts +++ b/packages/database/src/schema/nodes.ts @@ -9,7 +9,6 @@ import { timestamp, } from 'drizzle-orm/pg-core'; import { organizations } from './organizations'; -import { servers } from './servers'; export const nodes = pgTable('nodes', { id: uuid('id').defaultRandom().primaryKey(), @@ -32,14 +31,3 @@ export const nodes = pgTable('nodes', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); - -export const allocations = pgTable('allocations', { - id: uuid('id').defaultRandom().primaryKey(), - nodeId: uuid('node_id') - .notNull() - .references(() => nodes.id, { onDelete: 'cascade' }), - serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }), - ip: varchar('ip', { length: 45 }).notNull(), - port: integer('port').notNull(), - isDefault: boolean('is_default').default(false).notNull(), -}); diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index c817aa2..23e395a 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -1,5 +1,6 @@ import { createDb } from './client'; import { games } from './schema/games'; +import { users } from './schema/users'; async function seed() { const databaseUrl = process.env.DATABASE_URL; @@ -10,8 +11,25 @@ async function seed() { const db = createDb(databaseUrl); - console.log('Seeding games...'); + // Seed super admin + console.log('Seeding super admin...'); + // Password: admin123 (argon2id hash) + // In production, change this immediately after first login + const ADMIN_PASSWORD_HASH = + '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+daw'; + await db + .insert(users) + .values({ + email: 'admin@gamepanel.local', + username: 'admin', + passwordHash: ADMIN_PASSWORD_HASH, + isSuperAdmin: true, + }) + .onConflictDoNothing(); + + // Seed games + console.log('Seeding games...'); await db .insert(games) .values([ @@ -22,7 +40,7 @@ async function seed() { defaultPort: 25565, startupCommand: '/start', stopCommand: 'stop', - configFiles: JSON.stringify([ + configFiles: [ { path: 'server.properties', parser: 'properties', @@ -44,8 +62,8 @@ async function seed() { { path: 'whitelist.json', parser: 'json' }, { path: 'bukkit.yml', parser: 'yaml' }, { path: 'spigot.yml', parser: 'yaml' }, - ]), - environmentVars: JSON.stringify([ + ], + environmentVars: [ { key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true }, { key: 'TYPE', @@ -60,7 +78,7 @@ async function seed() { required: true, }, { key: 'MEMORY', default: '1G', description: 'JVM memory allocation', required: false }, - ]), + ], }, { slug: 'cs2', @@ -70,7 +88,7 @@ async function seed() { startupCommand: './srcds_run -game csgo -console -usercon +game_type 0 +game_mode 0 +mapgroup mg_active +map de_dust2', stopCommand: 'quit', - configFiles: JSON.stringify([ + configFiles: [ { path: 'csgo/cfg/server.cfg', parser: 'keyvalue', @@ -84,8 +102,8 @@ async function seed() { ], }, { path: 'csgo/cfg/autoexec.cfg', parser: 'keyvalue' }, - ]), - environmentVars: JSON.stringify([ + ], + environmentVars: [ { key: 'SRCDS_TOKEN', default: '', @@ -100,7 +118,7 @@ async function seed() { description: 'Max players', required: false, }, - ]), + ], }, ]) .onConflictDoNothing(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c328f..23bf11d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,15 @@ importers: argon2: specifier: ^0.41.0 version: 0.41.1 + drizzle-orm: + specifier: ^0.38.0 + version: 0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4) fastify: specifier: ^5.2.0 version: 5.7.4 + fastify-plugin: + specifier: ^5.0.0 + version: 5.1.0 pino-pretty: specifier: ^13.0.0 version: 13.1.3 @@ -66,6 +72,9 @@ importers: specifier: ^4.8.0 version: 4.8.3 devDependencies: + dotenv-cli: + specifier: ^8.0.0 + version: 8.0.0 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -128,6 +137,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.11 + dotenv-cli: + specifier: ^8.0.0 + version: 8.0.0 drizzle-kit: specifier: ^0.30.0 version: 0.30.6 @@ -1408,6 +1420,18 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dotenv-cli@8.0.0: + resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} + hasBin: true + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + drizzle-kit@0.30.6: resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} hasBin: true @@ -3468,6 +3492,17 @@ snapshots: dlv@1.1.3: {} + dotenv-cli@8.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 16.6.1 + dotenv-expand: 10.0.0 + minimist: 1.2.8 + + dotenv-expand@10.0.0: {} + + dotenv@16.6.1: {} + drizzle-kit@0.30.6: dependencies: '@drizzle-team/brocli': 0.10.2