Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bcb6542
feat: add `features` column to `users` table
AlAnNuB Oct 3, 2025
a66ad6d
feat: add default feature `read:activation_token` when creating `user`
AlAnNuB Oct 3, 2025
7adf4ca
fix: handle empty email list in `orchestrator.getLastEmail()`
AlAnNuB Oct 16, 2025
feef23f
feat: send activation email after `user` registration
AlAnNuB Oct 16, 2025
90b6835
feat: add `activation.findOneValidById()` and `orchestrator.extractUU…
AlAnNuB Oct 23, 2025
4cca835
feat: add `PATCH` into `api/v1/activations/[token_id]`
AlAnNuB Oct 23, 2025
e3da54f
feat: add `injectAnonymousOrUser` and `canRequest` middlewares to `/s…
AlAnNuB Oct 29, 2025
19c60dc
feat: create `Authorization` model and use it inside `/sessions` cont…
AlAnNuB Nov 8, 2025
46b313c
feat: require `read:session` to access `/user` endpoint
AlAnNuB Nov 12, 2025
5b4beae
feat: require update:user to access /api/v1/users/[username]
AlAnNuB Feb 13, 2026
f848439
feat: consider resource in authorization model
AlAnNuB Feb 13, 2026
b297915
feat: allow update:user:others to update other users
AlAnNuB Feb 13, 2026
9aa219a
feat: apply authorization.filterOutput() to all endpoints
AlAnNuB Feb 13, 2026
8d08a15
feat: validate user, feature and resource in authorization model
AlAnNuB Feb 13, 2026
68550c9
chore: update Node.js version to 24
AlAnNuB Feb 13, 2026
1992f7a
ci: align Node.js version with package.json
AlAnNuB Feb 13, 2026
95e4b40
chore: add migrations:up:dry npm script
AlAnNuB Feb 13, 2026
249f55d
refactor: improve error loggin in email.send()
AlAnNuB Feb 13, 2026
b878b25
fix: lint correction and some typing adjustments to fix errors in the…
AlAnNuB Feb 14, 2026
9b6a70f
fix: change email value
AlAnNuB Mar 18, 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 @@ -11,7 +11,7 @@ jobs:

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

- run: npm ci

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

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

- run: npm ci

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

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

- run: npm ci

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.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: "lts/hydrogen"
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
58 changes: 57 additions & 1 deletion infra/controller.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import * as cookie from "cookie";
import session from "models/session.js";
import user from "models/user.js";
import authorization from "models/authorization.js";

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

function onNoMatchHandler(request, response) {
Expand All @@ -16,7 +19,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 Down Expand Up @@ -56,13 +63,62 @@ async function clearSessionCookie(response) {
response.setHeader("Set-Cookie", setCookie);
}

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

injectAnonymousUser(request);
return next();
}

async function injectAuthenticatedUser(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,
};
}

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

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

function canRequest(feature) {
return (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,
canRequest,
};

export default controller;
2 changes: 1 addition & 1 deletion infra/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ async function query(queryObject) {
return result;
} catch (error) {
const serviceErrorObject = new ServiceError({
message: "Erro na conexão com Banco ou na Query",
message: "Erro na conexão com Banco ou na Query.",
cause: error,
});
throw serviceErrorObject;
Expand Down
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.js";

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 possível enviar o email.",
action: "Verifique se o serviço de email está disponível.",
cause: error,
context: mailOptions,
});
}
}

const email = {
Expand Down
29 changes: 26 additions & 3 deletions infra/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class InternalServerError extends Error {
cause,
});
this.name = "InternalServerError";
this.action = "Entre em contato com o suporte";
this.action = "Entre em contato com o suporte.";
this.statusCode = statusCode || 500;
}

Expand All @@ -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 = "ServiceError";
this.action = "Verifique se o serviço está disponível.";
this.action = action || "Verifique se o serviço está disponível.";
this.statusCode = 503;
this.context = context;
}

toJSON() {
Expand All @@ -34,6 +35,7 @@ export class ServiceError extends Error {
message: this.message,
action: this.action,
status_code: this.statusCode,
context: this.context,
};
}
}
Expand Down Expand Up @@ -117,3 +119,24 @@ export class MethodNotAllowedError 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,
status_code: this.statusCode,
};
}
}
11 changes: 11 additions & 0 deletions infra/migrations/1758481611584_add-features-to-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
exports.up = (pgm) => {
pgm.addColumn("users", {
features: {
type: "varchar[]",
nutNull: true,
default: "{}",
},
});
};

exports.down = false;
38 changes: 38 additions & 0 deletions infra/migrations/1760569579335_create-user-activation-tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
exports.up = (pgm) => {
pgm.createTable("user_activation_tokens", {
id: {
type: "uuid",
primaryKey: true,
default: pgm.func("gen_random_uuid()"),
},

used_at: {
type: "timestamptz",
notNull: false,
},

user_id: {
type: "uuid",
notNull: true,
},

expires_at: {
type: "timestamptz",
notNull: true,
},

created_at: {
type: "timestamptz",
notNull: true,
default: pgm.func("timezone('utc', now())"),
},

updated_at: {
type: "timestamptz",
notNull: true,
default: pgm.func("timezone('utc', now())"),
},
});
};

exports.down = false;
17 changes: 17 additions & 0 deletions infra/webserver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
function getOrigin() {
if (["test", "development"].includes(process.env.NODE_ENV)) {
return "http://localhost:3000";
}

if (process.env.VERCEL_ENV === "preview") {
return `https://${process.env.VERCEL_URL}`;
}

return "https://clone-tabnews.alannub.site";
}

const webserver = {
origin: getOrigin(),
};

export default webserver;
Loading
Loading