Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
da9cfff
feat: add `features` column to `users` table
GuiMRDS Jun 19, 2026
a4d78dd
feat: add default feature `read:activation_token` when creating `user`
GuiMRDS Jun 19, 2026
2e27aea
test: tests by email registraion flow
GuiMRDS Jun 22, 2026
9446dfa
feat: send activation email after `user` registration
GuiMRDS Jun 22, 2026
4550df5
feat: add `activation.findOneValidById()` and `orchestrator.extractUU…
GuiMRDS Jun 22, 2026
e1e29fa
feat: create method by activation and user models
GuiMRDS Jun 23, 2026
05859a3
feat: create `authorization` model and use it inside `\sessions` cont…
GuiMRDS Jul 3, 2026
52e2430
test: refact by tests for sessions valid
GuiMRDS Jul 3, 2026
46688bb
feat: require `read:session` to access `\user` endpoint
GuiMRDS Jul 3, 2026
7a7f6bc
test: create and refact test for `read:session` by users default and …
GuiMRDS Jul 3, 2026
d52cbef
feat: require `read:activation_token` to acess `\api\v1\activation\[t…
GuiMRDS Jul 3, 2026
b9facaa
feat: require `create:user` to access `api\v1\users`
GuiMRDS Jul 3, 2026
c39a800
feat: refacute endpoint `api\v1\users\[username]`
GuiMRDS Jul 4, 2026
71da16a
feat: consider `resource` in `authorization` model
GuiMRDS Jul 4, 2026
da3bd4f
feat: require `update:user` to acess `\api\v1\users\[username]`
GuiMRDS Jul 4, 2026
356de94
feat: created `filterOutput` for priveleged user
GuiMRDS Jul 6, 2026
8648fc1
feat: apply `authorization.filterOutput()` to all endpoints
GuiMRDS Jul 7, 2026
19304cc
feat: valiade `user`, `feature` and `resource` in `authorization` model
GuiMRDS Jul 7, 2026
d8fdbd9
chore: update Node.js version to 24
GuiMRDS Jul 8, 2026
3690aee
ci: align Node.js version with `package.json`
GuiMRDS Jul 8, 2026
80800d2
chore: add `migrations:up:dry` npm script
GuiMRDS Jul 8, 2026
df5f96b
refactor: improve error loggin in `email.send()`
GuiMRDS Jul 8, 2026
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
6 changes: 3 additions & 3 deletions .github/workflows/linting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "20"
node-version-file: "package.json"
cache: "npm"

- run: npm ci
Expand All @@ -28,7 +28,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "20"
node-version-file: "package.json"

- run: npm ci

Expand All @@ -45,7 +45,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "20"
node-version-file: "package.json"
cache: "npm"

- run: npm ci
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "24.14.0"
node-version-file: "package.json"

- run: npm ci

Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/hydrogen
24
163 changes: 163 additions & 0 deletions infra/authorization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { InternalServerError } from "./errors";

const avalableFeatures = [
// USER
"create:user",
"read:user",
"read:user:self",
"update:user",
"update:user:others",

// SESSIONS
"create:session",
"read:session",

// ACTIVATION_TOKEN
"read:activation_token",

// MIGRATIONS
"create:migration",
"read:migration",

// STATUS
"read:status",
"read:status:version",
];

function can(user, feature, resource) {
validateUser(user);
validateFeature(feature);

let authorized = false;

if (user.features.includes(feature)) {
authorized = true;
}

if (feature === "update:user" && resource) {
authorized = false;

if (user.id === resource.id || can(user, "update:user:others")) {
authorized = true;
}
}

return authorized;
}

function filterOutput(user, feature, insecureValues) {
validateUser(user);
validateFeature(feature);
validateResource(insecureValues);

if (feature === "read:user") {
return {
id: insecureValues.id,
username: insecureValues.username,
features: insecureValues.features,
created_at: insecureValues.created_at,
update_at: insecureValues.update_at,
};
}

if (feature === "read:user:self") {
if (user.id === insecureValues.id) {
return {
id: insecureValues.id,
username: insecureValues.username,
email: insecureValues.email,
features: insecureValues.features,
created_at: insecureValues.created_at,
update_at: insecureValues.update_at,
};
}
}

if (feature === "read:session") {
if (user.id === insecureValues.user_id) {
return {
id: insecureValues.id,
token: insecureValues.token,
user_id: insecureValues.user_id,
username: insecureValues.username,
expires_at: insecureValues.expires_at,
created_at: insecureValues.created_at,
updated_at: insecureValues.updated_at,
};
}
}

if (feature === "read:activation_token") {
return {
id: insecureValues.id,
user_id: insecureValues.user_id,
created_at: insecureValues.created_at,
update_at: insecureValues.update_at,
expires_at: insecureValues.expires_at,
used_at: insecureValues.used_at,
};
}

if (feature === "read:migration") {
return insecureValues.map((migration) => {
return {
path: migration.path,
name: migration.name,
timestamp: migration.timestamp,
};
});
}

if (feature === "read:status") {
const output = {
updated_at: insecureValues.updated_at,
dependencies: {
database: {
max_connections: insecureValues.dependencies.database.max_connections,
open_connections:
insecureValues.dependencies.database.open_connections,
},
},
};

if (user.features.includes("read:status:version")) {
output.dependencies.database.version =
insecureValues.dependencies.database.version;
}

return output;
}
}

function validateUser(user) {
if (!user || !user.features) {
throw new InternalServerError({
cause: "É necessario fornecer `user` no model `authorization`.",
});
}
}

function validateFeature(feature) {
if (!feature || !avalableFeatures.includes(feature)) {
throw new InternalServerError({
cause:
"É necessario fornecer `feature` conhecida no model `authorization`.",
});
}
}

function validateResource(resource) {
if (!resource) {
throw new InternalServerError({
cause:
"É necessario fornecer `resource`em `authorization.filterOutput()`.",
});
}
}

const authorization = {
can,
filterOutput,
};

export default authorization;
72 changes: 68 additions & 4 deletions infra/controller.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import * as cookie from "cookie";
import * as cookies from "cookie";
import session from "models/session";
import user from "models/user";
import authorization from "./authorization";

import {
InternalServerError,
MethodNotAllowedError,
ValidationError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
} from "infra/errors";

function onNoMatchHandler(request, response) {
Expand All @@ -14,7 +18,11 @@ function onNoMatchHandler(request, response) {
}

function onErrorHandler(error, request, response) {
if (error instanceof ValidationError || error instanceof NotFoundError) {
if (
error instanceof ValidationError ||
error instanceof NotFoundError ||
error instanceof ForbiddenError
) {
return response.status(error.statusCode).json(error);
}

Expand All @@ -33,7 +41,7 @@ function onErrorHandler(error, request, response) {
}

async function setSessionCookie(sessionToken, response) {
const setCookie = cookie.serialize("session_id", sessionToken, {
const setCookie = cookies.serialize("session_id", sessionToken, {
path: "/",
maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000,
secure: process.env.NODE_ENV === "production",
Expand All @@ -44,7 +52,7 @@ async function setSessionCookie(sessionToken, response) {
}

async function clearSessionCookie(response) {
const setCookie = cookie.serialize("session_id", "invalid", {
const setCookie = cookies.serialize("session_id", "invalid", {
path: "/",
maxAge: -1,
secure: process.env.NODE_ENV === "production",
Expand All @@ -54,13 +62,69 @@ async function clearSessionCookie(response) {
response.setHeader("Set-Cookie", setCookie);
}

async function injectAnonymousOrUser(request, response, next) {
if (request.cookies?.session_id) {
await injectAutenticatedUser(request);
return next();
}

injectAnonymousUser(request);
return next();
}

async function injectAutenticatedUser(request) {
const sessionToken = request.cookies.session_id;

const sessionObject = await session.findOneValidByToken(sessionToken);
const userObject = await user.findOneById(sessionObject.user_id);

request.context = {
...request.context,
user: userObject,
};
}

async function injectAnonymousUser(request) {
const anonymousUserObject = {
features: [
"read:activation_token",
"read:status",
"create:session",
"create:user",
],
};

request.context = {
...request.context,
user: anonymousUserObject,
};
}

function canRequest(feature) {
return function canRequestMiddleware(request, response, next) {
const userTryingToRequest = request.context.user;

if (authorization.can(userTryingToRequest, feature)) {
return next();
}

throw new ForbiddenError({
message: "Você não possui permissão para executar esta ação.",
action: `Verifique se o seu usuário possui a feature "${feature}"`,
});
};
}

const controller = {
errorHandlers: {
onNoMatch: onNoMatchHandler,
onError: onErrorHandler,
},
setSessionCookie,
clearSessionCookie,
injectAnonymousOrUser,
injectAutenticatedUser,
canRequest,
};

export default controller;
12 changes: 11 additions & 1 deletion infra/email.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import nodemailer from "nodemailer";
import { ServiceError } from "./errors";

const transporter = nodemailer.createTransport({
host: process.env.EMAIL_SMTP_HOST,
Expand All @@ -11,7 +12,16 @@ const transporter = nodemailer.createTransport({
});

async function send(mailOptions) {
await transporter.sendMail(mailOptions);
try {
await transporter.sendMail(mailOptions);
} catch (error) {
throw new ServiceError({
message: "Não foi possivel enviar o email.",
action: "Verifique se o serviço de email está disponivel.",
cause: error,
context: mailOptions,
});
}
}

const email = {
Expand Down
28 changes: 26 additions & 2 deletions infra/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ export class InternalServerError extends Error {
}

export class ServiceError extends Error {
constructor({ cause, message }) {
constructor({ cause, message, action, context }) {
super(message || "Serviço indisponível no momento.", {
cause,
});
this.name = "InternalServerError";
this.action = "Entre em contato com o suporte";
this.action = action || "Entre em contato com o suporte";
this.statusCode = 503;
this.context = context;
}

toJSON() {
Expand All @@ -34,6 +35,7 @@ export class ServiceError extends Error {
message: this.message,
action: this.action,
statusCode: this.statusCode,
context: this.context,
};
}
}
Expand Down Expand Up @@ -102,6 +104,28 @@ export class UnauthorizedError extends Error {
}
}

export class ForbiddenError extends Error {
constructor({ cause, message, action }) {
super(message || "Acesso negado.", {
cause,
});

this.name = "ForbiddenError";
this.action =
action || "Verifique as features necessárias antes de continuar.";
this.statusCode = 403;
}

toJSON() {
return {
name: this.name,
message: this.message,
action: this.action,
statusCode: this.statusCode,
};
}
}

export class MethodNotAllowedError extends Error {
constructor() {
super("Metodo não permitido para este endpoint.");
Expand Down
Loading
Loading