diff --git a/.cursor/rules/error-classes-own-files.mdc b/.cursor/rules/error-classes-own-files.mdc new file mode 100644 index 00000000..ba48a84f --- /dev/null +++ b/.cursor/rules/error-classes-own-files.mdc @@ -0,0 +1,13 @@ +--- +description: Declare each error class in its own file, not beside the type that uses it +globs: "{src,test}/**/*.{ts,js}" +alwaysApply: false +--- + +# Error classes in their own files + +Put every error class in a dedicated file whose name matches the exported type. Do not declare error classes in the same file as a port, adapter, use case, controller, or other primary type that throws or returns them. + +Group those files in an `error` directory next to the module they belong to. Import the error type from that file wherever it is constructed or checked. + +A base error and each specialized subclass are separate files. One public error class per file. diff --git a/.cursor/rules/prefer-classes-over-functions.mdc b/.cursor/rules/prefer-classes-over-functions.mdc new file mode 100644 index 00000000..dc488726 --- /dev/null +++ b/.cursor/rules/prefer-classes-over-functions.mdc @@ -0,0 +1,13 @@ +--- +description: Prefer a class over a standalone function when the unit is a collaborator +globs: "{src,test}/**/*.{ts,js}" +alwaysApply: false +--- + +# Prefer classes over standalone functions + +When adding behavior that other modules will depend on, prefer a class if that unit will be constructed and injected, hold configuration or instance state, implement a port, wrap a vendor library, or group several operations on the same concern. + +Use a standalone function only when the work is a pure transformation or catalog with no collaborators, no configuration to inject, and no reason to substitute an implementation in tests or at the composition root. + +If both shapes would work, choose the class. diff --git a/package.json b/package.json index 2f353c7c..dd5aed02 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "audit:fix": "npm audit fix" }, "dependencies": { + "@casl/ability": "6.8.0", "axios": "1.13.2", "bcrypt": "6.0.0", "body-parser": "2.2.0", @@ -68,6 +69,7 @@ "@stylistic/eslint-plugin": "5.5.0", "@types/cors": "2.8.19", "@types/express": "5.0.5", + "@types/jsonwebtoken": "9.0.10", "@types/morgan": "1.9.10", "@types/pg": "^8.16.0", "@types/react": "19.2.2", @@ -84,8 +86,8 @@ "globals": "16.5.0", "husky": "9.1.7", "npm-run-all": "4.1.5", - "tsx": "4.21.0", "supertest": "7.2.2", + "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.46.3", "vitest": "4.0.7" diff --git a/src/infrastructure/auth/CryptoRefreshToken.ts b/src/infrastructure/auth/CryptoRefreshToken.ts new file mode 100644 index 00000000..65c31fd2 --- /dev/null +++ b/src/infrastructure/auth/CryptoRefreshToken.ts @@ -0,0 +1,38 @@ +import { createHash, randomBytes } from 'node:crypto' + +import { RefreshTokenError } from '@/library/auth/error/RefreshTokenError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +export class CryptoRefreshToken implements RefreshToken { + generate(): Either { + try { + const token = randomBytes(32).toString('base64url') + const hashed = this.hash(token) + if (hashed.left()) { + return hashed + } + + return Either.right({ + token, + hash: hashed.value + }) + } catch (error) { + return Either.left(new RefreshTokenError({ + message: 'Failed to generate refresh token', + cause: error + })) + } + } + + hash(token: string): Either { + try { + return Either.right(createHash('sha256').update(token, 'utf8').digest('hex')) + } catch (error) { + return Either.left(new RefreshTokenError({ + message: 'Failed to hash refresh token', + cause: error + })) + } + } +} diff --git a/src/infrastructure/auth/JwtAccessToken.ts b/src/infrastructure/auth/JwtAccessToken.ts new file mode 100644 index 00000000..968ca247 --- /dev/null +++ b/src/infrastructure/auth/JwtAccessToken.ts @@ -0,0 +1,101 @@ +import jwt from 'jsonwebtoken' + +import { type AccessPayload, type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenError } from '@/library/auth/error/AccessTokenError' +import { AccessTokenExpiredError } from '@/library/auth/error/AccessTokenExpiredError' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' +import { Either } from '@/library/either/Either' + +const ACCESS_EXPIRES_IN = '15m' +const ACCESS_TYP = 'access' +const ACCESS_ALGORITHM = 'HS256' + +export class JwtAccessToken implements AccessToken { + private readonly secret: string + + constructor(params: { secret: string }) { + if (!params.secret) { + throw new Error('JWT secret is not set') + } + + this.secret = params.secret + } + + sign(params: { sub: number; sid: string; role: number }): Either { + try { + return Either.right(jwt.sign( + { + sub: String(params.sub), + sid: params.sid, + typ: ACCESS_TYP, + role: params.role + }, + this.secret, + { + algorithm: ACCESS_ALGORITHM, + expiresIn: ACCESS_EXPIRES_IN + } + )) + } catch (error) { + return Either.left(new AccessTokenError({ + message: 'Failed to sign access token', + cause: error + })) + } + } + + verify(token: string): Either { + try { + const decoded = jwt.verify(token, this.secret, { + algorithms: [ACCESS_ALGORITHM] + }) + if (typeof decoded === 'string') { + return Either.left(new AccessTokenInvalidError()) + } + + const payload = this.parseAccessPayload(decoded) + if (!payload) { + return Either.left(new AccessTokenInvalidError()) + } + + return Either.right(payload) + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return Either.left(new AccessTokenExpiredError({ cause: error })) + } + return Either.left(new AccessTokenInvalidError({ cause: error })) + } + } + + private parseAccessPayload(decoded: jwt.JwtPayload): AccessPayload | undefined { + const claims: Record = decoded + const sub = Number(decoded.sub) + const sid = claims.sid + const typ = claims.typ + const role = claims.role + const iat = claims.iat + const exp = claims.exp + + if ( + !Number.isInteger(sub) + || typeof sid !== 'string' + || sid.length === 0 + || typ !== ACCESS_TYP + || typeof role !== 'number' + || !Number.isInteger(role) + || typeof iat !== 'number' + || typeof exp !== 'number' + ) { + return undefined + } + + return { + sub, + sid, + typ: ACCESS_TYP, + role, + iat, + exp + } + } +} diff --git a/src/library/auth/AccessToken.ts b/src/library/auth/AccessToken.ts new file mode 100644 index 00000000..4cf157fc --- /dev/null +++ b/src/library/auth/AccessToken.ts @@ -0,0 +1,16 @@ +import { type AccessTokenError } from '@/library/auth/error/AccessTokenError' +import { type Either } from '@/library/either/Either' + +export interface AccessPayload { + sub: number + sid: string + typ: 'access' + role: number + iat: number + exp: number +} + +export interface AccessToken { + sign(params: { sub: number; sid: string; role: number }): Either + verify(token: string): Either +} diff --git a/src/library/auth/Manager.ts b/src/library/auth/Manager.ts new file mode 100644 index 00000000..3229273c --- /dev/null +++ b/src/library/auth/Manager.ts @@ -0,0 +1,47 @@ +import { + AbilityBuilder, + createMongoAbility, + subject, + type MongoAbility +} from '@casl/ability' + +export interface Rule { + action: A | A[] + resource: R + conditions?: object +} + +export class Manager { + private readonly ability: MongoAbility + + readonly rules: Rule[] + + constructor(params: { rules: Rule[] }) { + const builder = new AbilityBuilder(createMongoAbility) + + for (const rule of params.rules) { + builder.can(rule.action as string, rule.resource as string, rule.conditions) + } + + this.ability = builder.build() + this.rules = params.rules + } + + can(action: A, resource: R, record?: object): boolean { + if (record) { + return this.ability.can(action, subject(resource, record)) + } + + return this.ability.can(action, resource) + } + + canAny(actions: A[], resource: R, record?: object): boolean { + if (!actions.length) return false + return actions.some(action => this.can(action, resource, record)) + } + + canAll(actions: A[], resource: R, record?: object): boolean { + if (!actions.length) return false + return actions.every(action => this.can(action, resource, record)) + } +} diff --git a/src/library/auth/RefreshToken.ts b/src/library/auth/RefreshToken.ts new file mode 100644 index 00000000..903e27ec --- /dev/null +++ b/src/library/auth/RefreshToken.ts @@ -0,0 +1,7 @@ +import { type RefreshTokenError } from '@/library/auth/error/RefreshTokenError' +import { type Either } from '@/library/either/Either' + +export interface RefreshToken { + generate(): Either + hash(token: string): Either +} diff --git a/src/library/auth/createRules.ts b/src/library/auth/createRules.ts new file mode 100644 index 00000000..a80e587b --- /dev/null +++ b/src/library/auth/createRules.ts @@ -0,0 +1,45 @@ +import { type Rule } from './Manager' + +export const ACTIONS = [ + 'read', + 'create', + 'update', + 'delete', + 'export', + 'approve' +] as const + +export const RESOURCES = [ + 'Tombo', + 'Reino', + 'Familia', + 'Subfamilia', + 'Genero', + 'Especie', + 'Subespecie', + 'Variedade', + 'Autor', + 'Pais', + 'Estado', + 'Cidade', + 'Usuario', + 'Identificador', + 'Herbario', + 'Coletor', + 'Reflora', + 'SpeciesLink', + 'Pendencia', + 'Remessa', + 'Upload', + 'Relatorio', + 'Local', + 'Darwin', + 'Splink' +] as const + +export type Action = typeof ACTIONS[number] +export type Resource = typeof RESOURCES[number] + +export function createRules(_user: { id: number; tipo_usuario_id: number }): Rule[] { + return [] +} diff --git a/src/library/auth/error/AccessTokenError.ts b/src/library/auth/error/AccessTokenError.ts new file mode 100644 index 00000000..e8a063be --- /dev/null +++ b/src/library/auth/error/AccessTokenError.ts @@ -0,0 +1,3 @@ +import { BaseError } from '@/library/BaseError' + +export class AccessTokenError extends BaseError {} diff --git a/src/library/auth/error/AccessTokenExpiredError.ts b/src/library/auth/error/AccessTokenExpiredError.ts new file mode 100644 index 00000000..e5121bc7 --- /dev/null +++ b/src/library/auth/error/AccessTokenExpiredError.ts @@ -0,0 +1,10 @@ +import { AccessTokenError } from './AccessTokenError' + +export class AccessTokenExpiredError extends AccessTokenError { + constructor(params: { cause?: unknown } = {}) { + super({ + message: 'Access token expired', + cause: params.cause + }) + } +} diff --git a/src/library/auth/error/AccessTokenInvalidError.ts b/src/library/auth/error/AccessTokenInvalidError.ts new file mode 100644 index 00000000..3914141d --- /dev/null +++ b/src/library/auth/error/AccessTokenInvalidError.ts @@ -0,0 +1,10 @@ +import { AccessTokenError } from './AccessTokenError' + +export class AccessTokenInvalidError extends AccessTokenError { + constructor(params: { cause?: unknown } = {}) { + super({ + message: 'Access token is invalid', + cause: params.cause + }) + } +} diff --git a/src/library/auth/error/RefreshTokenError.ts b/src/library/auth/error/RefreshTokenError.ts new file mode 100644 index 00000000..baab47a6 --- /dev/null +++ b/src/library/auth/error/RefreshTokenError.ts @@ -0,0 +1,3 @@ +import { BaseError } from '@/library/BaseError' + +export class RefreshTokenError extends BaseError {} diff --git a/src/library/http/error/ForbiddenError.ts b/src/library/http/error/ForbiddenError.ts new file mode 100644 index 00000000..2c0b43da --- /dev/null +++ b/src/library/http/error/ForbiddenError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError' + +export class ForbiddenError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 403 }) + } +} diff --git a/test/unit/infrastructure/auth/CryptoRefreshToken.test.ts b/test/unit/infrastructure/auth/CryptoRefreshToken.test.ts new file mode 100644 index 00000000..554921f1 --- /dev/null +++ b/test/unit/infrastructure/auth/CryptoRefreshToken.test.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { + describe, + expect, + test +} from 'vitest' + +import { CryptoRefreshToken } from '@/infrastructure/auth/CryptoRefreshToken' + +describe('CryptoRefreshToken', () => { + const refreshToken = new CryptoRefreshToken() + + test('generated hash matches hash()', () => { + const generated = refreshToken.generate() + + expect(generated.right()).toBe(true) + if (!generated.right()) return + + const hashed = refreshToken.hash(generated.value.token) + expect(hashed.right()).toBe(true) + if (!hashed.right()) return + + expect(generated.value.hash).toBe(hashed.value) + expect(Buffer.from(generated.value.token, 'base64url')).toHaveLength(32) + }) + + test('two generated tokens differ', () => { + const first = refreshToken.generate() + const second = refreshToken.generate() + + expect(first.right()).toBe(true) + expect(second.right()).toBe(true) + if (!first.right() || !second.right()) return + + expect(first.value.token).not.toBe(second.value.token) + expect(first.value.hash).not.toBe(second.value.hash) + }) + + test('hash is hex SHA-256', () => { + const token = 'opaque-refresh-token' + const hashed = refreshToken.hash(token) + + expect(hashed.right()).toBe(true) + if (!hashed.right()) return + + expect(hashed.value).toHaveLength(64) + expect(hashed.value).toBe(createHash('sha256').update(token, 'utf8').digest('hex')) + expect(hashed.value).toMatch(/^[0-9a-f]{64}$/) + }) +}) diff --git a/test/unit/infrastructure/auth/JwtAccessToken.test.ts b/test/unit/infrastructure/auth/JwtAccessToken.test.ts new file mode 100644 index 00000000..5f4a0652 --- /dev/null +++ b/test/unit/infrastructure/auth/JwtAccessToken.test.ts @@ -0,0 +1,110 @@ +import jwt from 'jsonwebtoken' +import { + afterEach, + describe, + expect, + test, + vi +} from 'vitest' + +import { JwtAccessToken } from '@/infrastructure/auth/JwtAccessToken' +import { AccessTokenExpiredError } from '@/library/auth/error/AccessTokenExpiredError' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' + +const SECRET = 'test-secret-for-access-tokens' + +describe('JwtAccessToken', () => { + const accessToken = new JwtAccessToken({ secret: SECRET }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('round-trips access claims', () => { + const signed = accessToken.sign({ + sub: 7, + sid: 'session-1', + role: 2 + }) + + expect(signed.right()).toBe(true) + if (!signed.right()) return + + const result = accessToken.verify(signed.value) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value).toMatchObject({ + sub: 7, + sid: 'session-1', + typ: 'access', + role: 2 + }) + expect(typeof result.value.iat).toBe('number') + expect(typeof result.value.exp).toBe('number') + }) + + test('rejects an expired access token', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const signed = accessToken.sign({ + sub: 1, + sid: 'session-expired', + role: 1 + }) + expect(signed.right()).toBe(true) + if (!signed.right()) return + + vi.setSystemTime(new Date('2026-01-01T00:16:00.000Z')) + + const result = accessToken.verify(signed.value) + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenExpiredError) + }) + + test('rejects a garbage token', () => { + const result = accessToken.verify('not-a-jwt') + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenInvalidError) + }) + + test('rejects a token whose typ is not access', () => { + const token = jwt.sign( + { + sub: '1', + sid: 'session-2', + typ: 'refresh', + role: 1 + }, + SECRET, + { expiresIn: '15m' } + ) + + const result = accessToken.verify(token) + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenInvalidError) + }) + + test('rejects a token signed with another algorithm', () => { + const token = jwt.sign( + { + sub: '1', + sid: 'session-3', + typ: 'access', + role: 1 + }, + SECRET, + { + algorithm: 'HS384', + expiresIn: '15m' + } + ) + + const result = accessToken.verify(token) + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenInvalidError) + }) +}) diff --git a/test/unit/library/auth/Manager.test.ts b/test/unit/library/auth/Manager.test.ts new file mode 100644 index 00000000..2e330f1e --- /dev/null +++ b/test/unit/library/auth/Manager.test.ts @@ -0,0 +1,80 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { Manager, type Rule } from '@/library/auth/Manager' + +type Resource = 'Cidade' | 'Pais' +type Action = 'read' | 'update' | 'create' + +describe('Manager', () => { + test('allows a type-level action that has a rule', () => { + const manager = new Manager({ + rules: [{ action: 'read', resource: 'Cidade' }] + }) + + expect(manager.can('read', 'Cidade')).toBe(true) + expect(manager.can('update', 'Cidade')).toBe(false) + expect(manager.can('read', 'Pais')).toBe(false) + }) + + test('type-level can is true when the only matching rule has conditions', () => { + const rules: Rule[] = [ + { + action: 'update', + resource: 'Cidade', + conditions: { id: 10 } + } + ] + const manager = new Manager({ rules }) + + expect(manager.can('update', 'Cidade')).toBe(true) + }) + + test('record-level can matches conditions', () => { + const manager = new Manager({ + rules: [ + { + action: 'update', + resource: 'Cidade', + conditions: { id: 10 } + } + ] + }) + + expect(manager.can('update', 'Cidade', { id: 10 })).toBe(true) + expect(manager.can('update', 'Cidade', { id: 11 })).toBe(false) + }) + + test('canAny and canAll honor empty lists and optional records', () => { + const manager = new Manager({ + rules: [ + { action: 'read', resource: 'Cidade' }, + { + action: 'update', + resource: 'Cidade', + conditions: { id: 10 } + } + ] + }) + + expect(manager.canAny([], 'Cidade')).toBe(false) + expect(manager.canAll([], 'Cidade')).toBe(false) + expect(manager.canAny(['read', 'create'], 'Cidade')).toBe(true) + expect(manager.canAll(['read', 'update'], 'Cidade')).toBe(true) + expect(manager.canAll(['read', 'create'], 'Cidade')).toBe(false) + expect(manager.canAny(['update'], 'Cidade', { id: 10 })).toBe(true) + expect(manager.canAny(['update'], 'Cidade', { id: 11 })).toBe(false) + expect(manager.canAll(['update'], 'Cidade', { id: 10 })).toBe(true) + expect(manager.canAll(['update'], 'Cidade', { id: 11 })).toBe(false) + }) + + test('exposes the input rules', () => { + const rules: Rule[] = [{ action: 'read', resource: 'Pais' }] + const manager = new Manager({ rules }) + + expect(manager.rules).toBe(rules) + }) +}) diff --git a/test/unit/library/auth/create-rules.test.ts b/test/unit/library/auth/create-rules.test.ts new file mode 100644 index 00000000..7c413b22 --- /dev/null +++ b/test/unit/library/auth/create-rules.test.ts @@ -0,0 +1,22 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { createRules } from '@/library/auth/createRules' + +describe('createRules', () => { + test('returns no rules for curador, operador, identificador, and unknown tipo', () => { + const users = [ + { id: 1, tipo_usuario_id: 1 }, + { id: 2, tipo_usuario_id: 2 }, + { id: 3, tipo_usuario_id: 3 }, + { id: 99, tipo_usuario_id: 99 } + ] + + for (const user of users) { + expect(createRules(user)).toEqual([]) + } + }) +}) diff --git a/yarn.lock b/yarn.lock index c60eea79..2cfb2b34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -73,6 +73,13 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@casl/ability@6.8.0": + version "6.8.0" + resolved "https://registry.npmjs.org/@casl/ability/-/ability-6.8.0.tgz#db878b28339e275b6cb019a887b882b76a8abe3e" + integrity sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA== + dependencies: + "@ucast/mongo2js" "^1.3.0" + "@esbuild/aix-ppc64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" @@ -776,6 +783,14 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/jsonwebtoken@9.0.10": + version "9.0.10" + resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz#a7932a47177dcd4283b6146f3bd5c26d82647f09" + integrity sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA== + dependencies: + "@types/ms" "*" + "@types/node" "*" + "@types/methods@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" @@ -793,6 +808,11 @@ dependencies: "@types/node" "*" +"@types/ms@*": + version "2.1.0" + resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node@*": version "25.9.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.3.tgz#11dfe7a33e68fa5c560f0aa76cc5595621ef26b9" @@ -996,6 +1016,34 @@ "@typescript-eslint/types" "8.46.3" eslint-visitor-keys "^4.2.1" +"@ucast/core@1.10.2", "@ucast/core@^1.4.1": + version "1.10.2" + resolved "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz#30b6b893479823265368e528b61b042f752f2c92" + integrity sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g== + +"@ucast/js@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@ucast/js/-/js-3.1.0.tgz#2f965afd1fcf0b9c5ba720f79a21fbed3be6d59f" + integrity sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q== + dependencies: + "@ucast/core" "1.10.2" + +"@ucast/mongo2js@^1.3.0": + version "1.4.1" + resolved "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.1.tgz#2d8193212e2a79375d083e706cf5012f5d9cad7e" + integrity sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA== + dependencies: + "@ucast/core" "1.10.2" + "@ucast/js" "3.1.0" + "@ucast/mongo" "2.4.3" + +"@ucast/mongo@2.4.3": + version "2.4.3" + resolved "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz#92b1dd7c0ab06a907f2ab1422aa3027518ccc05e" + integrity sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA== + dependencies: + "@ucast/core" "^1.4.1" + "@vitest/coverage-v8@4.0.7": version "4.0.7" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.7.tgz#0449407f97b10c03230a29778064a85698821bc6"