feat: puntos de extensión por cliente para el menú, las rutas del frontend y los permisos del proxy del Service Layer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 15:39:18 -05:00
1 parent 8ba845cc23
commit 3caf94609d
10 files changed
+178 -31

No files matched your search

@@ -0,0 +1,15 @@
import type { SlResource } from './sl-resources.js';
/**
* Lo que la rama de un cliente suma al proxy del Service Layer (vacío en `main`).
*
* `CLIENT_RESOURCE_PATHS`: pantallas suyas (rutas de menú) que además pueden leer (`read`) o escribir (`write`) un
* recurso que ya existe, por nombre. Por ejemplo, «Inmovilizar lotes» crea solicitudes de traslado:
*
* InventoryTransferRequests: { write: ['/inventario/inmovilizar-lotes'], read: ['/inventario/inmovilizar-lotes'] },
*
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
*/
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {};
export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { SL_RESOURCE_MAP, SL_RESOURCES } from './sl-resources.js';
import { SL_RESOURCE_MAP, SL_RESOURCES, extendResources } from './sl-resources.js';
describe('recursos del Service Layer', () => {
it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => {
@@ -99,3 +99,24 @@ describe('recursos del Service Layer', () => {
expect(r.selectOnly).not.toContain('UserPassword');
});
});
describe('extensiones de recursos por cliente', () => {
const base = [
{ name: 'Docs', key: 'number', methods: ['GET', 'POST'], read: ['/a'], write: '/a' },
{ name: 'Cat', key: 'string', methods: ['GET'], read: null, write: [] },
] as const;
it('suma pantallas del cliente a los permisos de un recurso, sin tocar los de cualquier sesión', () => {
const r = extendResources(base as never, { Docs: { read: ['/x'], write: ['/x'] }, Cat: { read: ['/x'] } }, []);
expect(r[0]).toMatchObject({ read: ['/a', '/x'], write: ['/a', '/x'] });
expect(r[1].read).toBeNull();
});
it('agrega recursos nuevos y rechaza repetidos, inexistentes y EmployeesInfo', () => {
const nuevo = { name: 'U_TABLA', key: 'string', methods: ['GET'], read: null, write: [] } as never;
expect(extendResources(base as never, {}, [nuevo]).map((x) => x.name)).toEqual(['Docs', 'Cat', 'U_TABLA']);
expect(() => extendResources(base as never, { Nada: { read: ['/x'] } }, [])).toThrow(/no existe el recurso/);
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'Docs' } as never])).toThrow(/ya existe/);
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'EmployeesInfo' } as never])).toThrow(/EmployeesInfo/);
});
});
+36 -1
View File
@@ -1,3 +1,5 @@
import { CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES } from './sl-resources.extensions.js';
export type SlMethod = 'GET' | 'POST' | 'PATCH';
/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */
@@ -56,7 +58,7 @@ const queryAction = (name: string): SlResource => ({ name, key: 'string', method
*
* Las rutas de menú son las mismas que usará el frontend (permisos por pantalla).
*/
export const SL_RESOURCES: readonly SlResource[] = [
const BASE_SL_RESOURCES: readonly SlResource[] = [
// Ventas
document('Quotations', '/ventas/ofertas'),
document('Orders', '/ventas/ordenes'),
@@ -132,4 +134,37 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('AdditionalExpenses', 'number'),
];
const union = (a: RequiredPath | null, extra: readonly string[] | undefined): RequiredPath | null => {
// `null` = cualquier sesión: no se restringe por agregar pantallas
if (a === null || !extra?.length) return a;
return [...new Set([a, extra].flat())];
};
/**
* Suma lo que declara la rama de un cliente: pantallas suyas con permiso sobre recursos que ya existen
* (`paths`: por nombre de recurso) y recursos nuevos (tablas de usuario, objetos propios). `EmployeesInfo` nunca se
* registra (trae `U_WEB_PASS`); un recurso que no existe o repetido es un error.
*/
export function extendResources(
base: readonly SlResource[],
paths: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>>,
extra: readonly SlResource[],
): readonly SlResource[] {
const names = new Set(base.map((r) => r.name));
for (const name of Object.keys(paths)) {
if (!names.has(name)) throw new Error(`sl-resources.extensions: no existe el recurso «${name}»`);
}
for (const r of extra) {
if (r.name === 'EmployeesInfo') throw new Error('sl-resources.extensions: EmployeesInfo no se puede registrar (trae U_WEB_PASS)');
if (names.has(r.name)) throw new Error(`sl-resources.extensions: el recurso «${r.name}» ya existe`);
names.add(r.name);
}
return [
...base.map((r) => (paths[r.name] ? { ...r, read: union(r.read, paths[r.name].read), write: union(r.write, paths[r.name].write) } : r)),
...extra,
];
}
export const SL_RESOURCES: readonly SlResource[] = extendResources(BASE_SL_RESOURCES, CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES);
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));