Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ee54c94
feat: add `features` column to `users` table
adrianogtl Jul 23, 2026
b650d13
feat: add default feature `read:activation_token` when creating `user`
adrianogtl Jul 23, 2026
8ad68b7
fix: handle empty email list in `orchestrator.getLastEmail()`
adrianogtl Jul 24, 2026
65b2e98
feat: send activation email after `user` registration
adrianogtl Jul 24, 2026
5321068
feat: add `activation.findOneValidById()` and `orchestrator.extractUU…
adrianogtl Jul 25, 2026
fcb507d
feat: add `PATCH` `/api/v1/activations/[token_id]`
adrianogtl Jul 25, 2026
309784d
feat: add `injectAnonymousOrUser` and `canRequest` middlewares to `/s…
adrianogtl Jul 26, 2026
7c99154
feat: create `authorization` model and use it in `/sessions` controller
adrianogtl Jul 28, 2026
7ce7955
feat: require `read:session` to access `/user` endpoint
adrianogtl Jul 28, 2026
143d3fc
test: makes `orchestrator.createUser()` use optional chaining
adrianogtl Jul 30, 2026
657cf66
feat: require `read:activation_token` to access `/api/v1/activations/…
adrianogtl Jul 30, 2026
a32639e
feat: require `create:user` to access `api/v1/users`
adrianogtl Jul 31, 2026
863890c
feat: require `update:user` to access `/api/v1/users/[username]´
adrianogtl Aug 1, 2026
0932894
feat: consider `resource` in `authorization` model
adrianogtl Aug 3, 2026
71cd21f
feat: allow `update:user:others` to update other users
adrianogtl Aug 4, 2026
f08f88a
feat: apply `authorization.filterOutput()` to all endpoints
adrianogtl Aug 10, 2026
8225a68
feat: validate `user`, `feature` and `resource` in `authorization` model
adrianogtl Aug 12, 2026
0794b90
chore: update Node.js version to 24
adrianogtl Aug 13, 2026
0faaa18
ci: align Node.js version with `package.json`
adrianogtl Aug 13, 2026
a3d1877
chore: add `migrations:up:dry` npm script
adrianogtl Aug 13, 2026
026229b
refactor: improve error logging in `email.send()`
adrianogtl Aug 14, 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,11 +1,14 @@
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 @@ -14,7 +17,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 @@ -54,13 +61,62 @@ async function clearSessionCookie(response) {
response.setHeader("Set-Cookie", setCookie);
}

async function injectAnonymousOrUser(request, response, next) {
if (request.cookies?.session_id) {
await injectAuthenticatedUser(request);
} else {
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 anonymousUserObject = {
features: ["read:activation_token", "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: "You do not have permission to execute this action.",
action: `Check if your user has the 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.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: "It was not possible to send the email.",
action: "Check if the email service is avaiable.",
cause: error,
context: mailOptions,
});
}
}

const email = {
Expand Down
27 changes: 25 additions & 2 deletions infra/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ export class MethodNotAllowedError extends Error {
}

export class ServiceError extends Error {
constructor({ cause, message }) {
constructor({ cause, message, action, context }) {
super(message || "Service 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 @@ -55,6 +56,7 @@ export class ServiceError extends Error {
message: this.message,
action: this.action,
status_code: this.statusCode,
context: this.context,
};
}
}
Expand All @@ -79,6 +81,27 @@ export class ValidationError extends Error {
}
}

export class ForbiddenError extends Error {
constructor({ cause, message, action }) {
super(message || "Access Denied", {
cause,
});

this.name = "ForbiddenError";
this.action = action || "Check require features before continue.";
this.statusCode = 403;
}

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

export class UnauthorizedError extends Error {
constructor({ cause, message, action }) {
super(message || "User not authenticated", {
Expand Down
11 changes: 11 additions & 0 deletions infra/migrations/1784806098046_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/1784902867996_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-lime-psi.vercel.app";
}

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

export default webserver;
Loading
Loading