Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .cursor/rules/error-classes-own-files.mdc
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions .cursor/rules/prefer-classes-over-functions.mdc
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
38 changes: 38 additions & 0 deletions src/infrastructure/auth/CryptoRefreshToken.ts
Original file line number Diff line number Diff line change
@@ -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<RefreshTokenError, { token: string; hash: string }> {
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<RefreshTokenError, string> {
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
}))
}
}
}
101 changes: 101 additions & 0 deletions src/infrastructure/auth/JwtAccessToken.ts
Original file line number Diff line number Diff line change
@@ -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<AccessTokenError, string> {
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<AccessTokenError, AccessPayload> {
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<string, unknown> = 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
}
}
}
16 changes: 16 additions & 0 deletions src/library/auth/AccessToken.ts
Original file line number Diff line number Diff line change
@@ -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<AccessTokenError, string>
verify(token: string): Either<AccessTokenError, AccessPayload>
}
47 changes: 47 additions & 0 deletions src/library/auth/Manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
AbilityBuilder,
createMongoAbility,
subject,
type MongoAbility
} from '@casl/ability'

export interface Rule<R extends string = string, A extends string = string> {
action: A | A[]
resource: R
conditions?: object
}

export class Manager<R extends string, A extends string> {
private readonly ability: MongoAbility

readonly rules: Rule<R, A>[]

constructor(params: { rules: Rule<R, A>[] }) {
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))
}
}
7 changes: 7 additions & 0 deletions src/library/auth/RefreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { type RefreshTokenError } from '@/library/auth/error/RefreshTokenError'
import { type Either } from '@/library/either/Either'

export interface RefreshToken {
generate(): Either<RefreshTokenError, { token: string; hash: string }>
hash(token: string): Either<RefreshTokenError, string>
}
45 changes: 45 additions & 0 deletions src/library/auth/createRules.ts
Original file line number Diff line number Diff line change
@@ -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<Resource, Action>[] {
return []
}
3 changes: 3 additions & 0 deletions src/library/auth/error/AccessTokenError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { BaseError } from '@/library/BaseError'

export class AccessTokenError extends BaseError {}
10 changes: 10 additions & 0 deletions src/library/auth/error/AccessTokenExpiredError.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
}
10 changes: 10 additions & 0 deletions src/library/auth/error/AccessTokenInvalidError.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
}
3 changes: 3 additions & 0 deletions src/library/auth/error/RefreshTokenError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { BaseError } from '@/library/BaseError'

export class RefreshTokenError extends BaseError {}
7 changes: 7 additions & 0 deletions src/library/http/error/ForbiddenError.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Loading
Loading