Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6efb81a
chore: rename folder `test` to `tests`
IsaacIsrael Jan 16, 2026
7abe95f
chore: fix typos
IsaacIsrael Jan 17, 2026
b92b3d7
feat: add `features` column to `users` table
IsaacIsrael Jan 16, 2026
b654a36
feat: add default feature `read:activation_token` when creating `user`
IsaacIsrael Jan 16, 2026
09a81bf
feat: handle empty email list in `orchestrator.getLastEmail()`
IsaacIsrael Jan 17, 2026
c937a48
feat: send activation email after `user` registration
IsaacIsrael Jan 17, 2026
2aa8a94
feat: add `activation.findOneValidById()` and `orchestractor.extractU…
IsaacIsrael Jan 17, 2026
cb264df
feat: add `PATCH` `/api/v1/activations/[token_id]`
IsaacIsrael Jan 17, 2026
9b24020
feat: add `injectAnonymousOrUser` and `canRequest` middlewares to `/s…
IsaacIsrael Jan 17, 2026
1608877
feat: require `read:session` to access `/user` endpoint
IsaacIsrael Jan 19, 2026
de7a5aa
feat: require `read:activation_token` to access `/api/v1/activations/…
IsaacIsrael Jan 19, 2026
4f75752
feat: require `create:user` to access `api/v1/users`
IsaacIsrael Jan 19, 2026
3d67baa
feat: require `update:user` to access `api/v1/users/[username]`
IsaacIsrael Jan 19, 2026
56e65ce
feat: consider `resource` in `authorization` model
IsaacIsrael Jan 19, 2026
337c83c
feat: allow `update:user:others` to update other users
IsaacIsrael Jan 19, 2026
b43fbc3
refactor: update `createSession` to accept `user` object instead of `…
IsaacIsrael Jan 20, 2026
4ea21b7
feat: apply `authorization.filterOutput()` to all endpoints
IsaacIsrael Feb 3, 2026
8860458
feat: validate `user`, `feature` and `resource` in `authorization` model
IsaacIsrael Feb 9, 2026
d87e8e0
chore: update `Node.js` version to `24`
IsaacIsrael Feb 16, 2026
1309bbf
ci: align `Node.js` version with `package.json`
IsaacIsrael Feb 16, 2026
279f7d9
chore: add `migration:up:dry` npm script
IsaacIsrael Mar 12, 2026
2c90056
refactor: improve error loggin in `email.send()`
IsaacIsrael Mar 12, 2026
5c698b5
fix: update email sender address
IsaacIsrael Apr 9, 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
3 changes: 2 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
save-exact=true
save-exact=true
engine-strict=true
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/hydrogen
24
67 changes: 60 additions & 7 deletions infra/controller.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import * as cookie from "cookie";
import session from "models/session";
import user from "models/user";
import authorization from "models/authorization";

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

function onNoMatchHandler(request, response) {
const puclicErrorObjcet = new MethodNotAllowedError();
response.status(puclicErrorObjcet.statusCode).json(puclicErrorObjcet);
const publicErrorObject = new MethodNotAllowedError();
response.status(publicErrorObject.statusCode).json(publicErrorObject);
}

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 @@ -24,17 +31,17 @@ function onErrorHandler(error, request, response) {
return response.status(error.statusCode).json(error);
}

const puclicErrorObjcet = new InternalServerError({
const publicErrorObject = new InternalServerError({
cause: error,
});
console.error(puclicErrorObjcet);
response.status(puclicErrorObjcet.statusCode).json(puclicErrorObjcet);
console.error(publicErrorObject);
response.status(publicErrorObject.statusCode).json(publicErrorObject);
}

async function setSessionCookie(sessionToken, response) {
const setCookie = cookie.serialize("session_id", sessionToken, {
path: "/",
maxAge: session.EXPIRATION_IN_MILISECONS / 1000,
maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000,
secure: process.env.NODE_ENV === "production",
httpOnly: true,
});
Expand All @@ -51,13 +58,59 @@ async function clearSessionCookie(response) {
response.setHeader("Set-Cookie", setCookie);
}

async function injectAnonymousOrUser(request, response, next) {
let userObject;
if (request?.cookies.session_id) {
const sessionToken = request.cookies.session_id;
userObject = await getAuthenticatedUser(sessionToken);
} else {
userObject = await getAnonymousUser();
}

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

return next();
}

async function getAuthenticatedUser(sessionToken) {
const sessionObject = await session.findOneValidByToken(sessionToken);
const userObject = await user.findOneById(sessionObject.user_id);
return userObject;
}

async function getAnonymousUser() {
const anonymousUser = {
features: ["read:activation_token", "create:session", "create:user"],
};
return anonymousUser;
}

function canRequest(feature) {
return async function canRequestMiddleware(request, response, next) {
const userTryingToRequest = request.context.user;
if (authorization.can(userTryingToRequest, feature)) {
return next();
}

throw new ForbiddenError({
message: "User do not have permission to perform this action.",
action: `Check user permissions has a feature ${feature}.`,
});
};
}

const controller = {
errorHandlers: {
onNoMatch: onNoMatchHandler,
onError: onErrorHandler,
},
setSessionCookie,
clearSessionCookie,
injectAnonymousOrUser,
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: "It was not possble to sent the mail.",
action: "Check if the email service is avalible.",
cause: error,
context: mailOptions,
});
}
}

const email = {
Expand Down
26 changes: 24 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({ message, cause }) {
constructor({ message, cause, action, context }) {
super(message || "Service currently unavailable", {
cause,
});
this.name = "ServiceError";
this.action = "Check if the service is available";
this.action = action || "Check if the service is available";
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 @@ -116,3 +118,23 @@ export class UnauthorizedError extends Error {
};
}
}

export class ForbiddenError extends Error {
constructor({ message, cause, action }) {
super(message || "Access denied", {
cause,
});
this.name = "ForbiddenError";
this.action = action || "Check user permissions before to continue.";
this.statusCode = 403;
}

toJSON() {
return {
name: this.name,
message: this.message,
action: this.action,
status_code: this.statusCode,
};
}
}
4 changes: 2 additions & 2 deletions infra/migrations/1740080557717_create-users.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ exports.up = (pgm) => {
notNull: true,
unique: true,
},
//Why 254 in lenght? https://stackoverflow.com/a/1199238
//Why 254 in length? https://stackoverflow.com/a/1199238
email: {
type: "varchar(254)",
notNull: true,
unique: true,
},
//Why 60 in lenght? https://www.npmjs.com/package/bcrypt#hash-info
//Why 60 in length? https://www.npmjs.com/package/bcrypt#hash-info
password: {
type: "varchar(60)",
notNull: true,
Expand Down
11 changes: 11 additions & 0 deletions infra/migrations/1768554236308_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[]",
notNull: true,
default: "{}",
},
});
};

exports.down = false;
38 changes: 38 additions & 0 deletions infra/migrations/1768565094427_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;
4 changes: 2 additions & 2 deletions infra/scripts/wait-for-postgres.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ function checkPostgres() {
return checkPostgres();
}
loader.stopLoader();
console.log("🟢 Postgres esta aceitando conexões.\n");
console.log("🟢 Postgres is accepting connections..\n");
}

exec("docker exec postgres-dev pg_isready --host localhost", handleReturn);
}
loader.startLoader({
text: "🔴 Aguardando o postgres aceitar conexõe",
text: "🔴 Waiting for Postgres to accept connections...",
});
checkPostgres();
29 changes: 29 additions & 0 deletions infra/webserver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
function getOrigin() {
if (
["development", "test"].includes(process.env.NODE_ENV) &&
!process.env.CODESPACES
) {
return "http://localhost:3000";
}

if (process.env.CODESPACES === "true") {
const port = process.env.PORT ?? "3000";
const codespaceName = process.env.CODESPACE_NAME;
const forwardingDomain =
process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN;

return `https://${codespaceName}-${port}.${forwardingDomain}`;
}

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

return "https://iisrael.com.br";
}

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

export default webserver;
Loading
Loading