diff --git a/package.json b/package.json index fe5ccf5..c68fa0e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,17 @@ "node": ">=20" }, "pnpm": { - "onlyBuiltDependencies": ["@prisma/client", "@prisma/engines", "esbuild", "prisma"] + "onlyBuiltDependencies": [ + "@prisma/client", + "@prisma/engines", + "esbuild", + "prisma" + ] + }, + "devDependencies": { + "prisma": "6.19.3" + }, + "dependencies": { + "@prisma/client": "6.19.3" } } diff --git a/packages/server/package.json b/packages/server/package.json index dc5ed75..b65ba78 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,13 +10,17 @@ }, "dependencies": { "@agent-fox/shared": "workspace:*", - "express": "^5.0.0", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.3", "zod": "^3.24.0" }, "devDependencies": { - "@types/express": "^5.0.0", + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.10", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d5f1008..a436195 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import cors from 'cors'; +import authRouter from './routes/auth.js'; const app = express(); app.use(cors()); @@ -9,6 +10,8 @@ app.get('/api/health', (_req, res) => { res.json({ success: true, data: { status: 'ok' } }); }); +app.use('/api/auth', authRouter); + const port = process.env.SERVER_PORT || 3000; app.listen(port, () => { console.log(`Server running on port ${port}`); diff --git a/packages/server/src/lib/jwt.ts b/packages/server/src/lib/jwt.ts new file mode 100644 index 0000000..d9b1d40 --- /dev/null +++ b/packages/server/src/lib/jwt.ts @@ -0,0 +1,34 @@ +import jwt from 'jsonwebtoken'; + +const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret'; +const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'; +const ACCESS_EXPIRY = '15m'; +const REFRESH_EXPIRY = '7d'; + +export type TokenPayload = { + userId: string; + email: string; +}; + +export function generateAccessToken(payload: TokenPayload): string { + return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRY }); +} + +export function generateRefreshToken(payload: TokenPayload): string { + return jwt.sign(payload, REFRESH_SECRET, { expiresIn: REFRESH_EXPIRY }); +} + +export function verifyAccessToken(token: string): TokenPayload { + return jwt.verify(token, ACCESS_SECRET) as TokenPayload; +} + +export function verifyRefreshToken(token: string): TokenPayload { + return jwt.verify(token, REFRESH_SECRET) as TokenPayload; +} + +export function generateTokenPair(payload: TokenPayload) { + return { + accessToken: generateAccessToken(payload), + refreshToken: generateRefreshToken(payload), + }; +} diff --git a/packages/server/src/lib/password.ts b/packages/server/src/lib/password.ts new file mode 100644 index 0000000..fb6da05 --- /dev/null +++ b/packages/server/src/lib/password.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000..91c62f4 --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,26 @@ +import type { Request, Response, NextFunction } from 'express'; +import { verifyAccessToken, type TokenPayload } from '../lib/jwt.js'; + +declare global { + namespace Express { + interface Request { + user?: TokenPayload; + } + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } }); + return; + } + + try { + const token = header.slice(7); + req.user = verifyAccessToken(token); + next(); + } catch { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }); + } +} diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000..c53fff7 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,103 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { prisma } from '@agent-fox/shared'; +import { hashPassword, verifyPassword } from '../lib/password.js'; +import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router: RouterType = Router(); + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: z.string().min(1).max(100), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +router.post('/register', async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + + const { email, password, name } = parsed.data; + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + res.status(409).json({ success: false, error: { code: 'CONFLICT', message: 'Email already registered' } }); + return; + } + + const passwordHash = await hashPassword(password); + const user = await prisma.user.create({ + data: { email, passwordHash, name }, + }); + + const tokens = generateTokenPair({ userId: user.id, email: user.email }); + res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } }); +}); + +router.post('/login', async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + + const { email, password } = parsed.data; + const user = await prisma.user.findUnique({ where: { email } }); + + if (!user || !user.passwordHash) { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }); + return; + } + + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }); + return; + } + + const tokens = generateTokenPair({ userId: user.id, email: user.email }); + res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } }); +}); + +router.post('/refresh', async (req, res) => { + const { refreshToken } = req.body; + if (!refreshToken) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Refresh token required' } }); + return; + } + + try { + const payload = verifyRefreshToken(refreshToken); + const user = await prisma.user.findUnique({ where: { id: payload.userId } }); + if (!user) { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } }); + return; + } + const tokens = generateTokenPair({ userId: user.id, email: user.email }); + res.json({ success: true, data: tokens }); + } catch { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } }); + } +}); + +router.get('/me', requireAuth, async (req, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.userId }, + select: { id: true, email: true, name: true, avatarUrl: true }, + }); + if (!user) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }); + return; + } + res.json({ success: true, data: user }); +}); + +export default router; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a2b5c0..0286153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,15 @@ settings: importers: - .: {} + .: + dependencies: + '@prisma/client': + specifier: 6.19.3 + version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + devDependencies: + prisma: + specifier: 6.19.3 + version: 6.19.3(typescript@5.9.3) packages/mcp: dependencies: @@ -44,22 +52,34 @@ importers: '@agent-fox/shared': specifier: workspace:* version: link:../shared + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 cors: specifier: ^2.8.5 version: 2.8.6 express: specifier: ^5.0.0 version: 5.2.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 zod: specifier: ^3.24.0 version: 3.25.76 devDependencies: + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/cors': specifier: ^2.8.17 version: 2.8.19 '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -415,6 +435,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -433,6 +456,12 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@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.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -463,10 +492,17 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -563,6 +599,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -730,6 +769,16 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + 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==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -804,6 +853,27 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + 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==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -836,9 +906,17 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + nypm@0.6.5: resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} engines: {node: '>=18'} @@ -949,9 +1027,17 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -1319,6 +1405,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 25.5.0 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -1347,6 +1437,13 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.5.0 + + '@types/ms@2.1.0': {} + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 @@ -1380,6 +1477,11 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -1394,6 +1496,8 @@ snapshots: transitivePeerDependencies: - supports-color + buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} c12@3.1.0: @@ -1476,6 +1580,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.21.0: @@ -1677,6 +1785,30 @@ snapshots: json-schema-typed@8.0.2: {} + 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 + lightningcss-android-arm64@1.32.0: optional: true @@ -1726,6 +1858,20 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + 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: {} + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -1744,8 +1890,12 @@ snapshots: negotiator@1.0.0: {} + node-addon-api@8.7.0: {} + node-fetch-native@1.6.7: {} + node-gyp-build@4.8.4: {} + nypm@0.6.5: dependencies: citty: 0.2.2 @@ -1868,8 +2018,12 @@ snapshots: transitivePeerDependencies: - supports-color + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + semver@7.7.4: {} + send@1.2.1: dependencies: debug: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..c6268f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ packages: - "packages/*" + +onlyBuiltDependencies: + - bcrypt