feat(fastify-auth): create @bytelyst/fastify-auth package with JWT auth + request context
- createAuthMiddleware(): RS256 JWKS + HS256 fallback (parameterized) - createRequestContext(): productId validation + getUserId() - Fastify request type augmentation for jwtPayload - 15 tests passing
This commit is contained in:
parent
d10322095a
commit
f61a1f0b04
36
packages/fastify-auth/package.json
Normal file
36
packages/fastify-auth/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@bytelyst/fastify-auth",
|
||||
"version": "0.1.0",
|
||||
"description": "JWT auth middleware + request context for Fastify product backends",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastify": ">=5.0.0",
|
||||
"jose": ">=5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/errors": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fastify": "^5.2.1",
|
||||
"jose": "^6.0.8",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
80
packages/fastify-auth/src/auth.ts
Normal file
80
packages/fastify-auth/src/auth.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Configurable JWT auth middleware — RS256 JWKS verification with HS256 fallback.
|
||||
*
|
||||
* Factory function creates extractAuth() and requireRole() bound to the
|
||||
* provided config, eliminating the need for each product backend to maintain
|
||||
* its own copy.
|
||||
*/
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
|
||||
import type { AuthPayload, FastifyAuthOptions } from './types.js';
|
||||
|
||||
export function createAuthMiddleware(opts: FastifyAuthOptions) {
|
||||
// Lazy-init JWKS client (cached, auto-refreshed by jose)
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
let cachedJwksUrl: string | undefined;
|
||||
|
||||
function getJWKS(): ReturnType<typeof createRemoteJWKSet> | null {
|
||||
const url = opts.jwksUrl;
|
||||
if (!url) return null;
|
||||
if (jwks && cachedJwksUrl === url) return jwks;
|
||||
jwks = createRemoteJWKSet(new URL(url));
|
||||
cachedJwksUrl = url;
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function getHmacSecret(): Uint8Array {
|
||||
return new TextEncoder().encode(opts.jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and verify auth payload from an Authorization header.
|
||||
* Tries RS256 via JWKS first, falls back to HS256.
|
||||
*/
|
||||
async function extractAuth(req: { headers: { authorization?: string } }): Promise<AuthPayload> {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
const token = auth.slice(7);
|
||||
|
||||
// Try RS256 via JWKS first
|
||||
const remoteJWKS = getJWKS();
|
||||
if (remoteJWKS) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, remoteJWKS);
|
||||
const p = payload as unknown as AuthPayload;
|
||||
if (p.type !== 'access') throw new Error('Not an access token');
|
||||
return p;
|
||||
} catch {
|
||||
// Fall through to HS256
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HS256 (existing behavior)
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getHmacSecret());
|
||||
const p = payload as unknown as AuthPayload;
|
||||
if (p.type !== 'access') throw new Error('Not an access token');
|
||||
return p;
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require specific roles. Extracts auth first, then checks role.
|
||||
*/
|
||||
async function requireRole(
|
||||
req: { headers: { authorization?: string } },
|
||||
...roles: string[]
|
||||
): Promise<AuthPayload> {
|
||||
const payload = await extractAuth(req);
|
||||
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
|
||||
throw new ForbiddenError('Insufficient permissions');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
return { extractAuth, requireRole };
|
||||
}
|
||||
145
packages/fastify-auth/src/index.test.ts
Normal file
145
packages/fastify-auth/src/index.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SignJWT } from 'jose';
|
||||
import { createAuthMiddleware, createRequestContext } from './index.js';
|
||||
import type { JwtPayload } from './types.js';
|
||||
|
||||
const TEST_SECRET = 'test-jwt-secret-for-fastify-auth-package';
|
||||
|
||||
interface MockReq {
|
||||
headers: Record<string, string | undefined>;
|
||||
jwtPayload?: JwtPayload;
|
||||
}
|
||||
|
||||
function makeReq(token?: string): MockReq {
|
||||
return {
|
||||
headers: {
|
||||
authorization: token ? `Bearer ${token}` : undefined,
|
||||
},
|
||||
jwtPayload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function signToken(payload: Record<string, unknown>, secret = TEST_SECRET) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(new TextEncoder().encode(secret));
|
||||
}
|
||||
|
||||
describe('createAuthMiddleware', () => {
|
||||
const { extractAuth, requireRole } = createAuthMiddleware({
|
||||
jwtSecret: TEST_SECRET,
|
||||
});
|
||||
|
||||
it('extracts auth payload from valid access token', async () => {
|
||||
const token = await signToken({
|
||||
sub: 'user-1',
|
||||
email: 'test@test.com',
|
||||
role: 'admin',
|
||||
type: 'access',
|
||||
});
|
||||
const payload = await extractAuth(makeReq(token));
|
||||
expect(payload.sub).toBe('user-1');
|
||||
expect(payload.email).toBe('test@test.com');
|
||||
expect(payload.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedError when no Authorization header', async () => {
|
||||
await expect(extractAuth(makeReq())).rejects.toThrow('Unauthorized');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedError for invalid token', async () => {
|
||||
await expect(extractAuth(makeReq('bad-token'))).rejects.toThrow('Invalid or expired token');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedError for non-access token type', async () => {
|
||||
const token = await signToken({ sub: 'u1', type: 'refresh' });
|
||||
await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedError for wrong secret', async () => {
|
||||
const token = await signToken({ sub: 'u1', type: 'access' }, 'wrong');
|
||||
await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token');
|
||||
});
|
||||
|
||||
it('requireRole passes when role matches', async () => {
|
||||
const token = await signToken({
|
||||
sub: 'u1',
|
||||
role: 'admin',
|
||||
type: 'access',
|
||||
});
|
||||
const payload = await requireRole(makeReq(token), 'admin', 'superadmin');
|
||||
expect(payload.sub).toBe('u1');
|
||||
});
|
||||
|
||||
it('requireRole throws ForbiddenError when role does not match', async () => {
|
||||
const token = await signToken({
|
||||
sub: 'u1',
|
||||
role: 'viewer',
|
||||
type: 'access',
|
||||
});
|
||||
await expect(requireRole(makeReq(token), 'admin')).rejects.toThrow('Insufficient permissions');
|
||||
});
|
||||
|
||||
it('requireRole passes with no required roles (any authenticated user)', async () => {
|
||||
const token = await signToken({
|
||||
sub: 'u1',
|
||||
type: 'access',
|
||||
});
|
||||
const payload = await requireRole(makeReq(token));
|
||||
expect(payload.sub).toBe('u1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestContext', () => {
|
||||
const { getRequestProductId, getUserId } = createRequestContext({
|
||||
productId: 'testproduct',
|
||||
});
|
||||
|
||||
function makeFastifyReq(overrides?: {
|
||||
jwtPayload?: JwtPayload;
|
||||
headers?: Record<string, string | undefined>;
|
||||
}) {
|
||||
const req = makeReq();
|
||||
if (overrides?.jwtPayload !== undefined) req.jwtPayload = overrides.jwtPayload;
|
||||
if (overrides?.headers) Object.assign(req.headers, overrides.headers);
|
||||
return req as unknown as import('fastify').FastifyRequest;
|
||||
}
|
||||
|
||||
it('returns product ID for valid request', () => {
|
||||
expect(getRequestProductId(makeFastifyReq())).toBe('testproduct');
|
||||
});
|
||||
|
||||
it('returns product ID when JWT productId matches', () => {
|
||||
expect(
|
||||
getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'testproduct' } }))
|
||||
).toBe('testproduct');
|
||||
});
|
||||
|
||||
it('throws BadRequestError when JWT productId does not match', () => {
|
||||
expect(() =>
|
||||
getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'wrong' } }))
|
||||
).toThrow('Invalid productId');
|
||||
});
|
||||
|
||||
it('throws BadRequestError when X-Product-Id header does not match', () => {
|
||||
expect(() =>
|
||||
getRequestProductId(makeFastifyReq({ headers: { 'x-product-id': 'wrong' } }))
|
||||
).toThrow('Invalid productId');
|
||||
});
|
||||
|
||||
it('getUserId returns sub from JWT payload', () => {
|
||||
expect(getUserId(makeFastifyReq({ jwtPayload: { sub: 'user-42' } }))).toBe('user-42');
|
||||
});
|
||||
|
||||
it('getUserId throws when no JWT payload', () => {
|
||||
expect(() => getUserId(makeFastifyReq())).toThrow('Missing userId');
|
||||
});
|
||||
|
||||
it('getUserId throws when JWT has no sub', () => {
|
||||
expect(() => getUserId(makeFastifyReq({ jwtPayload: {} as JwtPayload }))).toThrow(
|
||||
'Missing userId'
|
||||
);
|
||||
});
|
||||
});
|
||||
8
packages/fastify-auth/src/index.ts
Normal file
8
packages/fastify-auth/src/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { createAuthMiddleware } from './auth.js';
|
||||
export { createRequestContext } from './request-context.js';
|
||||
export type {
|
||||
AuthPayload,
|
||||
JwtPayload,
|
||||
FastifyAuthOptions,
|
||||
RequestContextOptions,
|
||||
} from './types.js';
|
||||
47
packages/fastify-auth/src/request-context.ts
Normal file
47
packages/fastify-auth/src/request-context.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Configurable request context helpers for Fastify product backends.
|
||||
*
|
||||
* Factory function creates getRequestProductId() and getUserId() bound to
|
||||
* the provided product ID, eliminating hardcoded product IDs in each repo.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { BadRequestError } from '@bytelyst/errors';
|
||||
import type { RequestContextOptions } from './types.js';
|
||||
|
||||
export function createRequestContext(opts: RequestContextOptions) {
|
||||
const { productId } = opts;
|
||||
|
||||
/**
|
||||
* Extract productId from request. Validates against this backend's product ID.
|
||||
* Falls back to the configured productId since this is a product-specific backend.
|
||||
*/
|
||||
function getRequestProductId(req: FastifyRequest): string {
|
||||
// 1. From JWT
|
||||
const jwtPid = req.jwtPayload?.productId;
|
||||
if (jwtPid && jwtPid !== productId) {
|
||||
throw new BadRequestError(`Invalid productId: expected ${productId}, got ${jwtPid}`);
|
||||
}
|
||||
|
||||
// 2. From header
|
||||
const header = req.headers['x-product-id'];
|
||||
if (typeof header === 'string' && header.length > 0 && header !== productId) {
|
||||
throw new BadRequestError(`Invalid productId: expected ${productId}, got ${header}`);
|
||||
}
|
||||
|
||||
return productId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract userId from the JWT payload on the request.
|
||||
* Throws BadRequestError if no authenticated user is found.
|
||||
*/
|
||||
function getUserId(req: FastifyRequest): string {
|
||||
const sub = req.jwtPayload?.sub;
|
||||
if (!sub) {
|
||||
throw new BadRequestError('Missing userId — request must be authenticated');
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
return { getRequestProductId, getUserId };
|
||||
}
|
||||
44
packages/fastify-auth/src/types.ts
Normal file
44
packages/fastify-auth/src/types.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* JWT payload shape expected from platform-service tokens.
|
||||
* Re-exported from @bytelyst/auth for convenience.
|
||||
*/
|
||||
export interface AuthPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
productId?: string;
|
||||
type?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
}
|
||||
|
||||
/** JWT payload shape attached to req by the onRequest hook. */
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
productId?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** Options for creating the auth middleware. */
|
||||
export interface FastifyAuthOptions {
|
||||
/** HS256 symmetric secret for JWT verification. */
|
||||
jwtSecret: string;
|
||||
/** Optional RS256 JWKS endpoint URL (tried first, falls back to HS256). */
|
||||
jwksUrl?: string;
|
||||
}
|
||||
|
||||
/** Options for creating the request context helpers. */
|
||||
export interface RequestContextOptions {
|
||||
/** The product ID this backend serves (e.g. 'peakpulse', 'nomgap'). */
|
||||
productId: string;
|
||||
}
|
||||
|
||||
// Augment Fastify request to include parsed JWT payload
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
jwtPayload?: JwtPayload;
|
||||
}
|
||||
}
|
||||
9
packages/fastify-auth/tsconfig.json
Normal file
9
packages/fastify-auth/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@ -191,7 +191,7 @@ importers:
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
husky:
|
||||
specifier: ^9.0.0
|
||||
version: 9.1.7
|
||||
@ -288,7 +288,7 @@ importers:
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
husky:
|
||||
specifier: ^9.0.0
|
||||
version: 9.1.7
|
||||
@ -508,6 +508,25 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../api-client
|
||||
|
||||
packages/fastify-auth:
|
||||
dependencies:
|
||||
'@bytelyst/errors':
|
||||
specifier: workspace:*
|
||||
version: link:../errors
|
||||
devDependencies:
|
||||
fastify:
|
||||
specifier: ^5.2.1
|
||||
version: 5.7.4
|
||||
jose:
|
||||
specifier: ^6.0.8
|
||||
version: 6.1.3
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/fastify-core:
|
||||
dependencies:
|
||||
'@bytelyst/errors':
|
||||
@ -17772,14 +17791,14 @@ snapshots:
|
||||
msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
@ -17790,14 +17809,14 @@ snapshots:
|
||||
msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@ -23576,7 +23595,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@ -23659,7 +23678,7 @@ snapshots:
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
|
||||
Loading…
Reference in New Issue
Block a user