🛠 Unidad ISS-11 · Features Roles y Resources — capa 🛠 CONSTRUIR
🧠 Comprender este bloque → · 📝 Evaluación · ✅ GATE de la unidad
Capa Página Para qué 🧠 Aprender Features Roles y Resources comprender, explicar y relacionar 🛠 Construir esta página ejecutar, programar y verificar ✅ GATE Condiciones de cierre condición para pasar al bloque siguiente Esta es la guía ejecutable. Sigue los pasos en orden. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, PARCHEs, verificaciones y criterios, sin simplificar ni modernizar.
Fase II: Auth con RBAC — ISS-11 — Features Roles y Resources (catálogo de autorización)
Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto:
app-storelab-express-ii— Express 5 + TypeScript + Sequelize, arquitectura por features. - Ya construido: Fase I (Business), infraestructura de seguridad y modelos (ISS-09) y feature Users (ISS-10). - Recorrido obligatorio de una petición:HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II):../bd-storelab.md§14.2 (roles), §14.3 (resources) y §21 (catálogo semilla). - Capas, convenciones y reglas transversales:00-contexto.md.
Este ISS Título Features Roles y Resources (catálogo de autorización) Feature / tablas features/auth/roles/·roles—features/auth/resources/·resourcesAPI /api/roles…y/api/recursos…(JWT + RBAC)Depende de ISS-10 — Feature Users Habilita ISS-12 — Asignaciones y concesiones
Contenido de este ISS
- 16.1 Feature Roles — DTOs
- 16.2 Feature Roles — repository, service, controller y rutas
- 16.3 Feature Roles — seeder y swagger
- 16.4 Feature Resources — DTOs y catálogo semilla
- 16.5 Feature Resources — repository, service, controller y rutas
- 16.6 Feature Resources — seeder y swagger
- 16.7 Pruebas HTTP
Objetivo: construir los dos extremos del permiso:
Role— el sujeto de la autorización (a quién se concede).Resource— el objeto (qué se concede), un par(method, path).
Este ISS crea el CRUD administrativo de ambos y el catálogo semilla de los 58 recursos del sistema. Las concesiones (el acto de unirlos) son el ISS-12.
Bloqueado por: ISS-10.
Criterios de aceptación (ISS-11) — consolidados
- [ ] 16.1
features/auth/roles/dto/completo (create,update,patch,role-response,index) - [ ] 16.2
roles.repository.ts,roles.service.ts,roles.controller.ts,roles.routes.ts(JWT + RBAC) - [ ] 16.3
roles.seeder.ts(ADMIN, SELLER, idempotente) yroles.swagger.ts - [ ] 16.4
features/auth/resources/dto/+resource-catalog.tscon 58 recursos - [ ] 16.5
resources.repository.ts,resources.service.ts,resources.controller.ts,resources.routes.ts(JWT + RBAC) - [ ] 16.6
resources.seeder.ts(carga el catálogo) yresources.swagger.ts - [ ] 16.7 archivos
.httpde ambos features - [ ]
npx tsc --noEmitOK
16.1 Feature Roles — DTOs
: > src/features/auth/roles/dto/create-role.dto.ts
cat >> src/features/auth/roles/dto/create-role.dto.ts << 'EOF'
/**
* Datos de entrada de `POST /api/roles`.
* `name` se normaliza a MAYÚSCULAS en el modelo.
*/
export interface CreateRoleDto {
name: string;
description?: string | null;
status?: "active" | "inactive";
}
EOF
: > src/features/auth/roles/dto/update-role.dto.ts
cat >> src/features/auth/roles/dto/update-role.dto.ts << 'EOF'
/**
* Datos de entrada de `PUT /api/roles/:id` (reemplazo completo).
* `status` no está aquí: el estado solo cambia con el borrado lógico.
*/
export interface UpdateRoleDto {
name: string;
description?: string | null;
}
EOF
: > src/features/auth/roles/dto/patch-role.dto.ts
cat >> src/features/auth/roles/dto/patch-role.dto.ts << 'EOF'
import { UpdateRoleDto } from "./update-role.dto";
/** Datos de entrada de `PATCH /api/roles/:id` (actualización parcial). */
export type PatchRoleDto = Partial<UpdateRoleDto>;
EOF
: > src/features/auth/roles/dto/role-response.dto.ts
cat >> src/features/auth/roles/dto/role-response.dto.ts << 'EOF'
import { Role, RoleI } from "../role.model";
/** Respuesta HTTP de un rol. Sin campos internos: el DTO coincide con el modelo. */
export type RoleResponseDto = RoleI;
/** Mapper modelo -> DTO de respuesta (objeto plano). */
export function toRoleResponse(role: Role): RoleResponseDto {
return role.toJSON() as RoleI;
}
EOF
: > src/features/auth/roles/dto/index.ts
cat >> src/features/auth/roles/dto/index.ts << 'EOF'
export * from "./create-role.dto";
export * from "./update-role.dto";
export * from "./patch-role.dto";
export * from "./role-response.dto";
EOF
Un rol se identifica por
name(UK). El nombre no autoriza nada: crear un rolAUDITORno le concede ningún recurso; el rol nace sin permisos.
16.2 Feature Roles — repository, service, controller y rutas
: > src/features/auth/roles/roles.repository.ts
cat >> src/features/auth/roles/roles.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { Role } from "./role.model";
/**
* Capa Repository del feature Roles.
* Única que habla con Sequelize (el modelo `Role`).
*/
export class RolesRepository {
/** Todos los roles activos. */
public async findAllActive(): Promise<Role[]> {
return Role.findAll({ where: { status: "active" } });
}
/** Un rol por PK (o `null`). */
public async findById(id: number, transaction?: Transaction): Promise<Role | null> {
return Role.findByPk(id, { transaction });
}
/** Un rol por nombre normalizado a MAYÚSCULAS (o `null`). */
public async findByName(name: string): Promise<Role | null> {
return Role.findOne({ where: { name: name.trim().toUpperCase() } });
}
/** Inserta un rol. */
public async create(data: CreationAttributes<Role>): Promise<Role> {
return Role.create(data);
}
/** Persiste cambios sobre una instancia existente. */
public async update(role: Role, data: Partial<Role>): Promise<Role> {
return role.update(data);
}
/** Elimina físicamente una instancia. */
public async delete(role: Role): Promise<void> {
await role.destroy();
}
}
EOF
: > src/features/auth/roles/roles.service.ts
cat >> src/features/auth/roles/roles.service.ts << 'EOF'
import {
CreateRoleDto,
PatchRoleDto,
RoleResponseDto,
UpdateRoleDto,
toRoleResponse,
} from "./dto";
import { RolesRepository } from "./roles.repository";
import { Role } from "./role.model";
import { AppError } from "../../../shared/errors/app-error";
/**
* Capa Service del feature Roles.
*
* Regla de negocio: el nombre del rol es único. La autorización **nunca** se
* decide por el nombre, sino por las concesiones (`resource_roles`) asociadas;
* el nombre solo sirve para agrupar.
*/
export class RolesService {
public constructor(
private readonly repository: RolesRepository = new RolesRepository()
) {}
// ================== READ ==================
public async getAll(): Promise<RoleResponseDto[]> {
const roles = await this.repository.findAllActive();
return roles.map((role) => toRoleResponse(role));
}
public async getOne(id: number): Promise<RoleResponseDto> {
return toRoleResponse(await this.findOrFail(id));
}
// ================== CREATE ==================
public async create(body: CreateRoleDto): Promise<RoleResponseDto> {
if (!body.name) {
throw new AppError(400, "name is required");
}
await this.assertNameAvailable(body.name);
const role = await this.repository.create({
name: body.name,
description: body.description ?? null,
status: body.status ?? "active",
});
return toRoleResponse(role);
}
// ================== UPDATE ==================
public async updatePut(id: number, body: UpdateRoleDto): Promise<RoleResponseDto> {
const role = await this.findOrFail(id);
await this.assertNameAvailable(body.name, id);
await this.repository.update(role, {
name: body.name,
description: body.description ?? null,
});
return toRoleResponse(role);
}
public async updatePatch(id: number, body: PatchRoleDto): Promise<RoleResponseDto> {
const role = await this.findOrFail(id);
if (body.name) {
await this.assertNameAvailable(body.name, id);
}
await this.repository.update(role, body);
return toRoleResponse(role);
}
// ================== DELETE ==================
/** Eliminación física. */
public async deletePhysical(id: number): Promise<void> {
const role = await this.findOrFail(id, false);
await this.repository.delete(role);
}
/** Eliminación lógica -> `status = inactive`. Todos sus usuarios pierden ese rol. */
public async deleteLogical(id: number): Promise<RoleResponseDto> {
const role = await this.findOrFail(id);
await this.repository.update(role, { status: "inactive" });
return toRoleResponse(role);
}
// ================== HELPERS ==================
private async findOrFail(id: number, onlyActive = true): Promise<Role> {
const role = await this.repository.findById(id);
if (!role || (onlyActive && role.status !== "active")) {
throw new AppError(404, "Role not found");
}
return role;
}
private async assertNameAvailable(name: string, excludeId?: number): Promise<void> {
const existing = await this.repository.findByName(name);
if (existing && existing.id !== excludeId) {
throw new AppError(409, "Role name already in use");
}
}
}
EOF
: > src/features/auth/roles/roles.controller.ts
cat >> src/features/auth/roles/roles.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateRoleDto, PatchRoleDto, UpdateRoleDto } from "./dto";
import { RolesService } from "./roles.service";
/**
* Capa Controller del feature Roles.
* Solo HTTP: lee `req`, llama al service y arma la respuesta.
*/
export class RolesController extends BaseController {
public constructor(
private readonly service: RolesService = new RolesService()
) {
super();
}
// ================== READ ==================
public async getAll(_req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const roles = await this.service.getAll();
res.status(200).json({ roles });
});
}
public async getOne(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const role = await this.service.getOne(this.paramId(req));
res.status(200).json({ role });
});
}
// ================== CREATE ==================
public async create(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const role = await this.service.create(req.body as CreateRoleDto);
res.status(201).json({ role });
});
}
// ================== UPDATE ==================
public async updatePut(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const role = await this.service.updatePut(
this.paramId(req),
req.body as UpdateRoleDto
);
res.status(200).json({ role });
});
}
public async updatePatch(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const role = await this.service.updatePatch(
this.paramId(req),
req.body as PatchRoleDto
);
res.status(200).json({ role });
});
}
// ================== DELETE ==================
/** Eliminación física. */
public async deletePhysical(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const id = this.paramId(req);
await this.service.deletePhysical(id);
res.status(200).json({ message: "Role permanently deleted", id });
});
}
/** Eliminación lógica -> `status = inactive`. */
public async deleteLogical(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const role = await this.service.deleteLogical(this.paramId(req));
res.status(200).json({ message: "Role deactivated (logical delete)", role });
});
}
}
EOF
: > src/features/auth/roles/roles.routes.ts
cat >> src/features/auth/roles/roles.routes.ts << 'EOF'
import { Application } from "express";
import { RolesController } from "./roles.controller";
import { authenticate, authorize } from "../access";
/** Rutas del feature Roles — **modalidad 3 (JWT + RBAC)** en todas las operaciones. */
export class RolesRoutes {
public rolesController: RolesController = new RolesController();
public routes(app: Application): void {
// getAll
app
.route("/api/roles")
.get(authenticate, authorize, this.rolesController.getAll.bind(this.rolesController));
// getOne
app
.route("/api/roles/:id")
.get(authenticate, authorize, this.rolesController.getOne.bind(this.rolesController));
// create
app
.route("/api/roles")
.post(authenticate, authorize, this.rolesController.create.bind(this.rolesController));
// update (PUT / PATCH)
app
.route("/api/roles/:id")
.put(authenticate, authorize, this.rolesController.updatePut.bind(this.rolesController))
.patch(authenticate, authorize, this.rolesController.updatePatch.bind(this.rolesController));
// delete físico
app
.route("/api/roles/:id")
.delete(
authenticate,
authorize,
this.rolesController.deletePhysical.bind(this.rolesController)
);
// delete lógico
app
.route("/api/roles/:id/deactivate")
.patch(authenticate, authorize, this.rolesController.deleteLogical.bind(this.rolesController));
}
}
EOF
16.3 Feature Roles — seeder y swagger
: > src/features/auth/roles/roles.seeder.ts
cat >> src/features/auth/roles/roles.seeder.ts << 'EOF'
import { Role } from "./role.model";
/**
* Seeder del catálogo de roles (`roles`).
*
* Crea los dos roles de referencia del sistema. Es determinista (no usa datos
* aleatorios) e idempotente: `findOrCreate` por nombre y reactivación si ya
* existía inactivo.
*
* Los roles nacen **sin permisos**: las concesiones las crea el seeder de
* `resource_roles` (ADMIN recibe los 58 recursos, SELLER los 7 de operación).
*/
export const SEED_ROLES = [
{ name: "ADMIN", description: "Administración del sistema: gestiona usuarios, roles y permisos" },
{ name: "SELLER", description: "Operación de ventas: consulta catálogo y registra ventas" },
] as const;
export async function seedRoles(): Promise<number> {
let created = 0;
for (const item of SEED_ROLES) {
const [role, wasCreated] = await Role.findOrCreate({
where: { name: item.name },
defaults: { name: item.name, description: item.description, status: "active" },
});
if (wasCreated) {
created++;
continue;
}
if (role.status !== "active") {
await role.update({ status: "active" });
}
}
console.log(`✅ roles: catálogo reconciliado (${SEED_ROLES.length} roles, ${created} nuevos)`);
return created;
}
EOF
: > src/features/auth/roles/roles.swagger.ts
cat >> src/features/auth/roles/roles.swagger.ts << 'EOF'
import {
bearerSecurity,
forbiddenResponse,
invalidIdResponse,
notFoundResponse,
unauthorizedResponse,
} from "../../../shared/http/swagger-security";
/**
* Documentación OpenAPI del feature Roles.
*
* Modalidad: **JWT + RBAC** en todas las operaciones.
*
* Recordatorio de diseño: el **nombre** del rol no autoriza nada. Un rol
* `ADMIN` sin concesiones activas no habilita ninguna operación; la autorización
* se decide por las filas de `resource_roles`.
*/
export const rolesSwagger = {
tags: [
{ name: "Roles", description: "CRUD de roles (agrupadores de permisos) — **JWT + RBAC**" },
],
paths: {
"/api/roles": {
get: {
tags: ["Roles"],
summary: "Listar roles activos",
description: "JWT + RBAC — recurso `GET /api/roles`.",
security: bearerSecurity,
responses: {
"200": { description: "Lista de roles (`{ roles: [...] }`)" },
"401": unauthorizedResponse,
"403": forbiddenResponse,
},
},
post: {
tags: ["Roles"],
summary: "Crear rol",
description:
"JWT + RBAC — recurso `POST /api/roles`. El rol nace **sin permisos**: se conceden con `POST /api/concesiones-rol`.",
security: bearerSecurity,
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/RoleCreate" } },
},
},
responses: {
"201": { description: "Rol creado (`{ role }`)" },
"401": unauthorizedResponse,
"403": forbiddenResponse,
"409": { description: "Nombre de rol ya en uso" },
},
},
},
"/api/roles/{id}": {
get: {
tags: ["Roles"],
summary: "Obtener rol por id",
description: "JWT + RBAC — recurso `GET /api/roles/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Rol (`{ role }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
put: {
tags: ["Roles"],
summary: "Reemplazar rol (PUT)",
description: "JWT + RBAC — recurso `PUT /api/roles/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/RoleUpdate" } },
},
},
responses: {
"200": { description: "Rol actualizado (`{ role }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
patch: {
tags: ["Roles"],
summary: "Modificar rol (PATCH)",
description: "JWT + RBAC — recurso `PATCH /api/roles/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
content: {
"application/json": { schema: { $ref: "#/components/schemas/RolePatch" } },
},
},
responses: {
"200": { description: "Rol actualizado (`{ role }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
delete: {
tags: ["Roles"],
summary: "Eliminar rol (físico)",
description: "JWT + RBAC — recurso `DELETE /api/roles/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Eliminado (`{ message, id }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
},
"/api/roles/{id}/deactivate": {
patch: {
tags: ["Roles"],
summary: "Desactivar rol (borrado lógico)",
description:
"JWT + RBAC — recurso `PATCH /api/roles/:id/deactivate`. " +
"Efecto inmediato: todos los usuarios de ese rol pierden sus permisos (eslabón `roles` inactivo -> DENY).",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Desactivado (`{ message, role }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
},
},
components: {
schemas: {
Role: {
type: "object",
properties: {
id: { type: "integer", example: 1 },
name: { type: "string", example: "SELLER" },
description: { type: "string", nullable: true },
status: { type: "string", enum: ["active", "inactive"], example: "active" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
RoleCreate: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", example: "BUYER" },
description: { type: "string", nullable: true },
status: { type: "string", enum: ["active", "inactive"], default: "active" },
},
},
RoleUpdate: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
description: { type: "string", nullable: true },
},
},
RolePatch: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string", nullable: true },
},
},
},
},
};
EOF
16.4 Feature Resources — DTOs y catálogo semilla
: > src/features/auth/resources/dto/create-resource.dto.ts
cat >> src/features/auth/resources/dto/create-resource.dto.ts << 'EOF'
/**
* Datos de entrada de `POST /api/recursos`.
*
* `path` se guarda con el patrón (`/api/productos/:id`), no con un valor concreto.
*/
export interface CreateResourceDto {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
description?: string | null;
status?: "active" | "inactive";
}
EOF
: > src/features/auth/resources/dto/update-resource.dto.ts
cat >> src/features/auth/resources/dto/update-resource.dto.ts << 'EOF'
/**
* Datos de entrada de `PUT /api/recursos/:id` (reemplazo completo).
* `status` no está aquí: el estado solo cambia con el borrado lógico.
*/
export interface UpdateResourceDto {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
description?: string | null;
}
EOF
: > src/features/auth/resources/dto/patch-resource.dto.ts
cat >> src/features/auth/resources/dto/patch-resource.dto.ts << 'EOF'
import { UpdateResourceDto } from "./update-resource.dto";
/** Datos de entrada de `PATCH /api/recursos/:id` (actualización parcial). */
export type PatchResourceDto = Partial<UpdateResourceDto>;
EOF
: > src/features/auth/resources/dto/resource-response.dto.ts
cat >> src/features/auth/resources/dto/resource-response.dto.ts << 'EOF'
import { Resource, ResourceI } from "../resource.model";
/**
* Respuesta HTTP de un recurso. `resources` no tiene campos internos, así que el
* DTO coincide con el modelo; se declara igualmente para que la API quede
* desacoplada del modelo (cambiar el modelo no cambia el contrato por accidente).
*/
export type ResourceResponseDto = ResourceI;
/** Mapper modelo -> DTO de respuesta (objeto plano). */
export function toResourceResponse(resource: Resource): ResourceResponseDto {
return resource.toJSON() as ResourceI;
}
EOF
: > src/features/auth/resources/dto/index.ts
cat >> src/features/auth/resources/dto/index.ts << 'EOF'
export * from "./create-resource.dto";
export * from "./update-resource.dto";
export * from "./patch-resource.dto";
export * from "./resource-response.dto";
EOF
El catálogo es la fuente única: define los 58 recursos, de los que se derivan tanto las filas de resources como las concesiones de los roles. La marca seller: true designa los 7 recursos de operación.
: > src/features/auth/resources/resource-catalog.ts
cat >> src/features/auth/resources/resource-catalog.ts << 'EOF'
/**
* Catálogo de los **58 recursos** del sistema (fuente única).
*
* Un recurso es un par `(method, path)`; un permiso es la concesión de un
* recurso a un rol. Este archivo es la definición en código del catálogo que
* puebla el seeder de `resources` y del que se derivan las concesiones de los
* roles (`ADMIN` recibe los 58; `SELLER`, los 7 marcados con `seller: true`).
*
* Composición (referencia: `docs/bd-storelab.md` §21):
*
* | Grupo | Recursos |
* |----------------------------------------------|---------:|
* | Clientes | 7 |
* | Tipos de producto | 7 |
* | Productos | 7 |
* | Ventas | 3 |
* | Detalle de ventas | 1 |
* | Usuarios (+ cambio de contraseña + permisos) | 9 |
* | Roles | 7 |
* | Recursos | 7 |
* | Asignaciones usuario-rol | 5 |
* | Concesiones rol-recurso | 5 |
* | **Total** | **58** |
*
* Nota: las operaciones de sesión (`/api/sesion/*` y `/api/sesiones/*`) **no**
* son recursos RBAC. Son las modalidades OPEN y JWT: no dependen de la matriz
* de permisos, sino de poseer (o no) una identidad válida.
*/
export interface CatalogResource {
method: string;
path: string;
description: string;
/** `true` si el rol `SELLER` recibe esta concesión (7 en total). */
seller?: boolean;
}
export const RESOURCE_CATALOG: readonly CatalogResource[] = [
// ── Clientes (7) ──────────────────────────────────────────────
{ method: "GET", path: "/api/clientes", description: "Listar clientes", seller: true },
{ method: "GET", path: "/api/clientes/:id", description: "Consultar cliente", seller: true },
{ method: "POST", path: "/api/clientes", description: "Crear cliente" },
{ method: "PUT", path: "/api/clientes/:id", description: "Reemplazar cliente" },
{ method: "PATCH", path: "/api/clientes/:id", description: "Modificar cliente" },
{ method: "DELETE", path: "/api/clientes/:id", description: "Eliminar cliente" },
{ method: "PATCH", path: "/api/clientes/:id/deactivate", description: "Desactivar cliente" },
// ── Tipos de producto (7) ─────────────────────────────────────
{ method: "GET", path: "/api/tipos-producto", description: "Listar tipos de producto" },
{ method: "GET", path: "/api/tipos-producto/:id", description: "Consultar tipo de producto" },
{ method: "POST", path: "/api/tipos-producto", description: "Crear tipo de producto" },
{ method: "PUT", path: "/api/tipos-producto/:id", description: "Reemplazar tipo de producto" },
{ method: "PATCH", path: "/api/tipos-producto/:id", description: "Modificar tipo de producto" },
{ method: "DELETE", path: "/api/tipos-producto/:id", description: "Eliminar tipo de producto" },
{
method: "PATCH",
path: "/api/tipos-producto/:id/deactivate",
description: "Desactivar tipo de producto",
},
// ── Productos (7) ─────────────────────────────────────────────
{ method: "GET", path: "/api/productos", description: "Listar productos", seller: true },
{ method: "GET", path: "/api/productos/:id", description: "Consultar producto", seller: true },
{ method: "POST", path: "/api/productos", description: "Crear producto" },
{ method: "PUT", path: "/api/productos/:id", description: "Reemplazar producto" },
{ method: "PATCH", path: "/api/productos/:id", description: "Modificar producto" },
{ method: "DELETE", path: "/api/productos/:id", description: "Eliminar producto" },
{ method: "PATCH", path: "/api/productos/:id/deactivate", description: "Desactivar producto" },
// ── Ventas (3) ────────────────────────────────────────────────
{ method: "GET", path: "/api/ventas", description: "Listar ventas", seller: true },
{ method: "GET", path: "/api/ventas/:id", description: "Consultar venta", seller: true },
{ method: "POST", path: "/api/ventas", description: "Registrar venta", seller: true },
// ── Detalle de ventas (1) ─────────────────────────────────────
{ method: "GET", path: "/api/detalle-ventas", description: "Listar detalle de ventas" },
// ── Usuarios (9) ──────────────────────────────────────────────
{ method: "GET", path: "/api/usuarios", description: "Listar usuarios" },
{ method: "GET", path: "/api/usuarios/:id", description: "Consultar usuario" },
{ method: "POST", path: "/api/usuarios", description: "Crear usuario" },
{ method: "PUT", path: "/api/usuarios/:id", description: "Reemplazar usuario" },
{ method: "PATCH", path: "/api/usuarios/:id", description: "Modificar usuario" },
{ method: "DELETE", path: "/api/usuarios/:id", description: "Eliminar usuario" },
{ method: "PATCH", path: "/api/usuarios/:id/deactivate", description: "Desactivar usuario" },
{
method: "PATCH",
path: "/api/usuarios/:id/password",
description: "Cambiar contraseña de usuario",
},
{
method: "GET",
path: "/api/usuarios/:id/permisos",
description: "Consultar permisos efectivos del usuario",
},
// ── Roles (7) ─────────────────────────────────────────────────
{ method: "GET", path: "/api/roles", description: "Listar roles" },
{ method: "GET", path: "/api/roles/:id", description: "Consultar rol" },
{ method: "POST", path: "/api/roles", description: "Crear rol" },
{ method: "PUT", path: "/api/roles/:id", description: "Reemplazar rol" },
{ method: "PATCH", path: "/api/roles/:id", description: "Modificar rol" },
{ method: "DELETE", path: "/api/roles/:id", description: "Eliminar rol" },
{ method: "PATCH", path: "/api/roles/:id/deactivate", description: "Desactivar rol" },
// ── Recursos (7) ──────────────────────────────────────────────
{ method: "GET", path: "/api/recursos", description: "Listar recursos" },
{ method: "GET", path: "/api/recursos/:id", description: "Consultar recurso" },
{ method: "POST", path: "/api/recursos", description: "Crear recurso" },
{ method: "PUT", path: "/api/recursos/:id", description: "Reemplazar recurso" },
{ method: "PATCH", path: "/api/recursos/:id", description: "Modificar recurso" },
{ method: "DELETE", path: "/api/recursos/:id", description: "Eliminar recurso" },
{ method: "PATCH", path: "/api/recursos/:id/deactivate", description: "Desactivar recurso" },
// ── Asignaciones usuario ↔ rol (5) ────────────────────────────
{ method: "GET", path: "/api/asignaciones-rol", description: "Listar asignaciones usuario-rol" },
{
method: "GET",
path: "/api/asignaciones-rol/:id",
description: "Consultar asignación usuario-rol",
},
{
method: "POST",
path: "/api/asignaciones-rol",
description: "Asignar rol a usuario",
},
{
method: "PATCH",
path: "/api/asignaciones-rol/:id/deactivate",
description: "Retirar rol a usuario",
},
{
method: "PATCH",
path: "/api/asignaciones-rol/:id/reactivate",
description: "Reactivar rol a usuario",
},
// ── Concesiones rol ↔ recurso (5) ─────────────────────────────
{ method: "GET", path: "/api/concesiones-rol", description: "Listar concesiones rol-recurso" },
{
method: "GET",
path: "/api/concesiones-rol/:id",
description: "Consultar concesión rol-recurso",
},
{
method: "POST",
path: "/api/concesiones-rol",
description: "Conceder recurso a rol",
},
{
method: "PATCH",
path: "/api/concesiones-rol/:id/deactivate",
description: "Retirar recurso a rol",
},
{
method: "PATCH",
path: "/api/concesiones-rol/:id/reactivate",
description: "Reactivar recurso a rol",
},
];
/** Recursos que recibe el rol `SELLER` (7). Derivado del catálogo, no duplicado. */
export const SELLER_RESOURCES: readonly CatalogResource[] = RESOURCE_CATALOG.filter(
(resource) => resource.seller === true
);
EOF
Composición del catálogo:
| Grupo | Recursos |
|---|---|
| Clientes | 7 |
| Tipos de producto | 7 |
| Productos | 7 |
| Ventas | 3 |
| Detalle de ventas | 1 |
| Usuarios (+ cambio de contraseña + permisos) | 9 |
| Roles | 7 |
| Recursos | 7 |
| Asignaciones usuario-rol | 5 |
| Concesiones rol-recurso | 5 |
| Total | 58 |
Las operaciones de sesión no son recursos.
/api/sesion/*y/api/sesiones/*no dependen de la matriz de permisos, sino de poseer (o no) una identidad válida. Por eso son OPEN o JWT, nunca JWT + RBAC.
16.5 Feature Resources — repository, service, controller y rutas
: > src/features/auth/resources/resources.repository.ts
cat >> src/features/auth/resources/resources.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { Resource } from "./resource.model";
import { normalizePath } from "../../../shared/auth/resource-match";
/**
* Capa Repository del feature Resources.
* Única que habla con Sequelize (el modelo `Resource`).
*/
export class ResourcesRepository {
/** Todos los recursos activos. */
public async findAllActive(): Promise<Resource[]> {
return Resource.findAll({ where: { status: "active" } });
}
/** Un recurso por PK (o `null`). */
public async findById(id: number, transaction?: Transaction): Promise<Resource | null> {
return Resource.findByPk(id, { transaction });
}
/** Un recurso por su par `(method, path)` (o `null`). */
public async findByOperation(method: string, path: string): Promise<Resource | null> {
return Resource.findOne({
where: { method: method.trim().toUpperCase(), path: normalizePath(path.trim()) },
});
}
/** Inserta un recurso. */
public async create(data: CreationAttributes<Resource>): Promise<Resource> {
return Resource.create(data);
}
/** Persiste cambios sobre una instancia existente. */
public async update(resource: Resource, data: Partial<Resource>): Promise<Resource> {
return resource.update(data);
}
/** Elimina físicamente una instancia. */
public async delete(resource: Resource): Promise<void> {
await resource.destroy();
}
}
EOF
: > src/features/auth/resources/resources.service.ts
cat >> src/features/auth/resources/resources.service.ts << 'EOF'
import {
CreateResourceDto,
PatchResourceDto,
ResourceResponseDto,
UpdateResourceDto,
toResourceResponse,
} from "./dto";
import { ResourcesRepository } from "./resources.repository";
import { Resource } from "./resource.model";
import { AppError } from "../../../shared/errors/app-error";
/**
* Capa Service del feature Resources.
*
* Reglas de negocio: la tupla `(method, path)` es única. Se comprueba antes de
* escribir para responder 409 con un mensaje útil en lugar de dejar reventar la
* restricción única de la base de datos como 500.
*/
export class ResourcesService {
public constructor(
private readonly repository: ResourcesRepository = new ResourcesRepository()
) {}
// ================== READ ==================
public async getAll(): Promise<ResourceResponseDto[]> {
const resources = await this.repository.findAllActive();
return resources.map((resource) => toResourceResponse(resource));
}
public async getOne(id: number): Promise<ResourceResponseDto> {
return toResourceResponse(await this.findOrFail(id));
}
// ================== CREATE ==================
public async create(body: CreateResourceDto): Promise<ResourceResponseDto> {
if (!body.method || !body.path) {
throw new AppError(400, "method and path are required");
}
await this.assertOperationAvailable(body.method, body.path);
const resource = await this.repository.create({
method: body.method,
path: body.path,
description: body.description ?? null,
status: body.status ?? "active",
});
return toResourceResponse(resource);
}
// ================== UPDATE ==================
public async updatePut(id: number, body: UpdateResourceDto): Promise<ResourceResponseDto> {
const resource = await this.findOrFail(id);
await this.assertOperationAvailable(body.method, body.path, id);
await this.repository.update(resource, {
method: body.method,
path: body.path,
description: body.description ?? null,
});
return toResourceResponse(resource);
}
public async updatePatch(id: number, body: PatchResourceDto): Promise<ResourceResponseDto> {
const resource = await this.findOrFail(id);
const method = body.method ?? resource.method;
const path = body.path ?? resource.path;
await this.assertOperationAvailable(method, path, id);
await this.repository.update(resource, body);
return toResourceResponse(resource);
}
// ================== DELETE ==================
/** Eliminación física. */
public async deletePhysical(id: number): Promise<void> {
const resource = await this.findOrFail(id, false);
await this.repository.delete(resource);
}
/** Eliminación lógica -> `status = inactive`. Deshabilita el punto de acceso. */
public async deleteLogical(id: number): Promise<ResourceResponseDto> {
const resource = await this.findOrFail(id);
await this.repository.update(resource, { status: "inactive" });
return toResourceResponse(resource);
}
// ================== HELPERS ==================
private async findOrFail(id: number, onlyActive = true): Promise<Resource> {
const resource = await this.repository.findById(id);
if (!resource || (onlyActive && resource.status !== "active")) {
throw new AppError(404, "Resource not found");
}
return resource;
}
/** 409 si otro recurso ya declara el mismo `(method, path)`. */
private async assertOperationAvailable(
method: string,
path: string,
excludeId?: number
): Promise<void> {
const existing = await this.repository.findByOperation(method, path);
if (existing && existing.id !== excludeId) {
throw new AppError(409, `Resource ${method.toUpperCase()} ${path} already exists`);
}
}
}
EOF
: > src/features/auth/resources/resources.controller.ts
cat >> src/features/auth/resources/resources.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import {
CreateResourceDto,
PatchResourceDto,
UpdateResourceDto,
} from "./dto";
import { ResourcesService } from "./resources.service";
/**
* Capa Controller del feature Resources.
* Solo HTTP: lee `req`, llama al service y arma la respuesta.
*/
export class ResourcesController extends BaseController {
public constructor(
private readonly service: ResourcesService = new ResourcesService()
) {
super();
}
// ================== READ ==================
public async getAll(_req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const resources = await this.service.getAll();
res.status(200).json({ resources });
});
}
public async getOne(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const resource = await this.service.getOne(this.paramId(req));
res.status(200).json({ resource });
});
}
// ================== CREATE ==================
public async create(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const resource = await this.service.create(req.body as CreateResourceDto);
res.status(201).json({ resource });
});
}
// ================== UPDATE ==================
public async updatePut(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const resource = await this.service.updatePut(
this.paramId(req),
req.body as UpdateResourceDto
);
res.status(200).json({ resource });
});
}
public async updatePatch(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const resource = await this.service.updatePatch(
this.paramId(req),
req.body as PatchResourceDto
);
res.status(200).json({ resource });
});
}
// ================== DELETE ==================
/** Eliminación física. */
public async deletePhysical(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const id = this.paramId(req);
await this.service.deletePhysical(id);
res.status(200).json({ message: "Resource permanently deleted", id });
});
}
/** Eliminación lógica -> `status = inactive`. */
public async deleteLogical(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const resource = await this.service.deleteLogical(this.paramId(req));
res.status(200).json({ message: "Resource deactivated (logical delete)", resource });
});
}
}
EOF
: > src/features/auth/resources/resources.routes.ts
cat >> src/features/auth/resources/resources.routes.ts << 'EOF'
import { Application } from "express";
import { ResourcesController } from "./resources.controller";
import { authenticate, authorize } from "../access";
/** Rutas del feature Resources — **modalidad 3 (JWT + RBAC)** en todas las operaciones. */
export class ResourcesRoutes {
public resourcesController: ResourcesController = new ResourcesController();
public routes(app: Application): void {
// getAll
app
.route("/api/recursos")
.get(authenticate, authorize, this.resourcesController.getAll.bind(this.resourcesController));
// getOne
app
.route("/api/recursos/:id")
.get(authenticate, authorize, this.resourcesController.getOne.bind(this.resourcesController));
// create
app
.route("/api/recursos")
.post(authenticate, authorize, this.resourcesController.create.bind(this.resourcesController));
// update (PUT / PATCH)
app
.route("/api/recursos/:id")
.put(
authenticate,
authorize,
this.resourcesController.updatePut.bind(this.resourcesController)
)
.patch(
authenticate,
authorize,
this.resourcesController.updatePatch.bind(this.resourcesController)
);
// delete físico
app
.route("/api/recursos/:id")
.delete(
authenticate,
authorize,
this.resourcesController.deletePhysical.bind(this.resourcesController)
);
// delete lógico
app
.route("/api/recursos/:id/deactivate")
.patch(
authenticate,
authorize,
this.resourcesController.deleteLogical.bind(this.resourcesController)
);
}
}
EOF
POST /api/recursos con un (method, path) ya existente → 409: lo impone la restricción UQ(method, path).
16.6 Feature Resources — seeder y swagger
El seeder es idempotente y reconciliador: findOrCreate por (method, path) y reactivación si la fila estaba inactiva. Reejecutarlo deja el catálogo exacto, sin duplicados.
: > src/features/auth/resources/resources.seeder.ts
cat >> src/features/auth/resources/resources.seeder.ts << 'EOF'
import { Resource } from "./resource.model";
import { RESOURCE_CATALOG } from "./resource-catalog";
/**
* Seeder del catálogo de recursos (`resources`).
*
* A diferencia de los seeders de business, este **no usa datos aleatorios**: los
* 58 recursos son un catálogo determinista definido en `resource-catalog.ts`.
* Es idempotente por partida doble: `findOrCreate` por `(method, path)` y
* reactivación de las filas que ya existían inactivas, de modo que volver a
* ejecutarlo reconcilia el catálogo sin duplicar ni perder concesiones.
*/
export async function seedResources(): Promise<number> {
let created = 0;
for (const item of RESOURCE_CATALOG) {
const [resource, wasCreated] = await Resource.findOrCreate({
where: { method: item.method, path: item.path },
defaults: {
method: item.method,
path: item.path,
description: item.description,
status: "active",
},
});
if (wasCreated) {
created++;
continue;
}
if (resource.status !== "active") {
await resource.update({ status: "active" });
}
}
console.log(
`✅ resources: catálogo reconciliado (${RESOURCE_CATALOG.length} recursos, ${created} nuevos)`
);
return created;
}
EOF
: > src/features/auth/resources/resources.swagger.ts
cat >> src/features/auth/resources/resources.swagger.ts << 'EOF'
import {
bearerSecurity,
forbiddenResponse,
invalidIdResponse,
notFoundResponse,
unauthorizedResponse,
} from "../../../shared/http/swagger-security";
/**
* Documentación OpenAPI del feature Resources.
*
* Modalidad: **JWT + RBAC** en todas las operaciones.
*
* Un recurso es un par `(method, path)` con la ruta **en patrón**
* (`/api/productos/:id`). `GET` y `POST` sobre la misma ruta son dos recursos
* distintos y se conceden por separado.
*/
export const resourcesSwagger = {
tags: [
{
name: "Recursos",
description:
"Catálogo de puntos de acceso protegibles: par `(method, path)` — **JWT + RBAC**",
},
],
paths: {
"/api/recursos": {
get: {
tags: ["Recursos"],
summary: "Listar recursos activos",
description: "JWT + RBAC — recurso `GET /api/recursos`.",
security: bearerSecurity,
responses: {
"200": { description: "Lista de recursos (`{ resources: [...] }`)" },
"401": unauthorizedResponse,
"403": forbiddenResponse,
},
},
post: {
tags: ["Recursos"],
summary: "Crear recurso",
description:
"JWT + RBAC — recurso `POST /api/recursos`. Alta de un nuevo punto de acceso; concederlo a un rol no requiere desplegar código.",
security: bearerSecurity,
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ResourceCreate" } },
},
},
responses: {
"201": { description: "Recurso creado (`{ resource }`)" },
"401": unauthorizedResponse,
"403": forbiddenResponse,
"409": { description: "La tupla `(method, path)` ya existe" },
},
},
},
"/api/recursos/{id}": {
get: {
tags: ["Recursos"],
summary: "Obtener recurso por id",
description: "JWT + RBAC — recurso `GET /api/recursos/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Recurso (`{ resource }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
put: {
tags: ["Recursos"],
summary: "Reemplazar recurso (PUT)",
description: "JWT + RBAC — recurso `PUT /api/recursos/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ResourceUpdate" } },
},
},
responses: {
"200": { description: "Recurso actualizado (`{ resource }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
patch: {
tags: ["Recursos"],
summary: "Modificar recurso (PATCH)",
description: "JWT + RBAC — recurso `PATCH /api/recursos/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
content: {
"application/json": { schema: { $ref: "#/components/schemas/ResourcePatch" } },
},
},
responses: {
"200": { description: "Recurso actualizado (`{ resource }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
delete: {
tags: ["Recursos"],
summary: "Eliminar recurso (físico)",
description: "JWT + RBAC — recurso `DELETE /api/recursos/:id`.",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Eliminado (`{ message, id }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
},
"/api/recursos/{id}/deactivate": {
patch: {
tags: ["Recursos"],
summary: "Desactivar recurso (borrado lógico)",
description:
"JWT + RBAC — recurso `PATCH /api/recursos/:id/deactivate`. " +
"Efecto inmediato: ningún rol puede autorizar ese punto de acceso (eslabón `resources` inactivo -> DENY).",
security: bearerSecurity,
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
"200": { description: "Desactivado (`{ message, resource }`)" },
"400": invalidIdResponse,
"401": unauthorizedResponse,
"403": forbiddenResponse,
"404": notFoundResponse,
},
},
},
},
components: {
schemas: {
Resource: {
type: "object",
properties: {
id: { type: "integer", example: 1 },
method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"], example: "GET" },
path: { type: "string", example: "/api/productos/:id" },
description: { type: "string", nullable: true },
status: { type: "string", enum: ["active", "inactive"], example: "active" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
ResourceCreate: {
type: "object",
required: ["method", "path"],
properties: {
method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
path: { type: "string", example: "/api/reportes/:id" },
description: { type: "string", nullable: true },
status: { type: "string", enum: ["active", "inactive"], default: "active" },
},
},
ResourceUpdate: {
type: "object",
required: ["method", "path"],
properties: {
method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
path: { type: "string" },
description: { type: "string", nullable: true },
},
},
ResourcePatch: {
type: "object",
properties: {
method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
path: { type: "string" },
description: { type: "string", nullable: true },
},
},
},
},
};
EOF
16.7 Pruebas HTTP
: > src/features/auth/roles/http/roles.get.http
cat >> src/features/auth/roles/http/roles.get.http << 'EOF'
### Feature Roles — CRUD (modalidad JWT + RBAC)
### El NOMBRE del rol no autoriza nada: la autorización son las filas de `resource_roles`.
@baseUrl = http://localhost:4000
# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "admin",
"password": "Admin123!"
}
# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "seller",
"password": "Seller123!"
}
@token = {{loginAdmin.response.body.$.access_token}}
### getAll — recurso `GET /api/roles` (ADMIN y SELLER)
GET {{baseUrl}}/api/roles
Authorization: Bearer {{token}}
### getOne
GET {{baseUrl}}/api/roles/1
Authorization: Bearer {{token}}
### CREATE — nace SIN permisos
POST {{baseUrl}}/api/roles
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "AUDITOR",
"description": "Solo lectura de catálogo"
}
### UPDATE PUT
PUT {{baseUrl}}/api/roles/3
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "AUDITOR",
"description": "Solo lectura de catálogo y ventas"
}
### UPDATE PATCH
PATCH {{baseUrl}}/api/roles/3
Authorization: Bearer {{token}}
Content-Type: application/json
{
"description": "Auditoría operativa"
}
### DELETE lógico — efecto inmediato: todos sus usuarios pierden esos permisos (403)
PATCH {{baseUrl}}/api/roles/3/deactivate
Authorization: Bearer {{token}}
### DELETE físico
DELETE {{baseUrl}}/api/roles/3
Authorization: Bearer {{token}}
### 403 — SELLER no tiene concedido `GET /api/roles`
GET {{baseUrl}}/api/roles
Authorization: Bearer {{loginSeller.response.body.$.access_token}}
EOF
: > src/features/auth/resources/http/resources.get.http
cat >> src/features/auth/resources/http/resources.get.http << 'EOF'
### Feature Resources — catálogo de puntos de acceso (modalidad JWT + RBAC)
### Un recurso es el par (method, path) con la ruta EN PATRÓN: /api/productos/:id
### GET y POST sobre la misma ruta son DOS recursos distintos.
@baseUrl = http://localhost:4000
# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json
{
"identifier": "admin",
"password": "Admin123!"
}
@token = {{loginAdmin.response.body.$.access_token}}
### getAll — el catálogo completo (58 recursos sembrados)
GET {{baseUrl}}/api/recursos
Authorization: Bearer {{token}}
### getOne
GET {{baseUrl}}/api/recursos/25
Authorization: Bearer {{token}}
### CREATE — alta de un punto de acceso nuevo
### Concederlo después a un rol no requiere desplegar código.
POST {{baseUrl}}/api/recursos
Authorization: Bearer {{token}}
Content-Type: application/json
{
"method": "GET",
"path": "/api/reportes/ventas/:id",
"description": "Consultar reporte de ventas"
}
### 409 — la tupla (method, path) ya existe
POST {{baseUrl}}/api/recursos
Authorization: Bearer {{token}}
Content-Type: application/json
{
"method": "GET",
"path": "/api/clientes",
"description": "Duplicado"
}
### UPDATE PUT
PUT {{baseUrl}}/api/recursos/59
Authorization: Bearer {{token}}
Content-Type: application/json
{
"method": "GET",
"path": "/api/reportes/ventas/:id",
"description": "Reporte de ventas por id"
}
### DELETE lógico — ningún rol puede ya autorizar ese endpoint (403 para todos)
PATCH {{baseUrl}}/api/recursos/59/deactivate
Authorization: Bearer {{token}}
### DELETE físico
DELETE {{baseUrl}}/api/recursos/59
Authorization: Bearer {{token}}
EOF
Verificación
SELECT COUNT(*) FROM resources; -- 58
SELECT COUNT(*) FROM roles; -- 2
SELECT name, status FROM roles; -- ADMIN, SELLER
DoD del ISS-11
- [ ] Todos los criterios de aceptación (16.1 … 16.7) cumplidos
- [ ]
resourcestiene 58 filas yUQ(method, path)impide duplicados (409) - [ ] Los roles se crean sin permisos; concederlos es el ISS-12
- [ ]
npx tsc --noEmitsin errores ynpm run db:seedidempotente
✅ GATE de la unidad ISS-11 — este bloque no añade ningún criterio nuevo.
Las condiciones de cierre son exactamente las que define el ISS técnico de esta misma página:
- 📋 Criterios de aceptación → Criterios de aceptación
- 🧩 Cierre de la unidad → Cierre de la unidad
- 🧠 Autoevaluación → Evaluación del cuaderno
Con el GATE en verde queda habilitado ISS-12 · Features RoleUsers y ResourceRoles.
Navegación de la ruta: ← ISS-11 · 🧠 Aprender · ↑ Ruta Express · → ISS-12 · 🧠 Aprender