312 lines
18 KiB
Markdown
312 lines
18 KiB
Markdown
# Proveedor de relaciones tipadas v1 · Paso 2.3
|
|
|
|
El primer proveedor real adapta `assets:directory-membership` a los contratos de
|
|
lectura de `@metis/relationship-contracts`. Consulta las pertenencias del paso 2.2;
|
|
no crea otra tabla de relaciones, no reconstruye agregados ni cambia comandos,
|
|
revisiones o identidad del dominio Windows. No modifica puestos, perfiles,
|
|
AccessWorkspace, SMB ni las relaciones anteriores entre activos.
|
|
|
|
## Registro y composición
|
|
|
|
`apps/server/src/infrastructure/http/relationship-runtime.ts` construye el registro
|
|
estático durante `createIdentityRuntime`, registra la definición exportada por
|
|
Assets e inyecta directamente servicios propietarios y autorización. El resultado
|
|
se expone como `runtime.relationships`. No hay estado global, Service Locator,
|
|
búsqueda dinámica de implementaciones ni dependencias circulares.
|
|
|
|
```ts
|
|
createRelationshipRegistry(
|
|
["assets", "resources"],
|
|
[DIRECTORY_MEMBERSHIP_DEFINITION],
|
|
["directory:domain"],
|
|
);
|
|
```
|
|
|
|
La definición conserva propietario Assets, origen `assets:asset`, destino
|
|
`resources:resource` con capacidad `directory:domain`, dirección dirigida,
|
|
cardinalidad vigente de un dominio por activo y varios activos por dominio,
|
|
representación `record`, historial `revisions`, etiquetas directa e inversa
|
|
traducibles y escritura `owner-command`.
|
|
|
|
El registro valida formas, duplicados y proveedores al arrancar y congela sus
|
|
copias internas. Su tercer argumento declara capacidades semánticas cuyo namespace
|
|
no corresponde a un módulo: **no se registra un proveedor ficticio `directory`**.
|
|
Se conserva la aceptación anterior de capacidades del namespace de un proveedor
|
|
registrado. Los duplicados o identificadores inválidos del catálogo de capacidades
|
|
producen `INVALID_REGISTRY`; referencias a proveedores/capacidades no declarados
|
|
producen `UNREGISTERED_PROVIDER`.
|
|
|
|
Servicios incorporados:
|
|
|
|
| Propietario | Implementación | Composición pública |
|
|
| --- | --- | --- |
|
|
| Assets | `DirectoryMembershipRelationshipQuery` | `assets.services.relationshipReads(registry, access, domainSummaries).memberships` |
|
|
| Assets | `AssetEntitySummaryResolver` | mismo resultado, `.summaries` |
|
|
| Resources | `DirectoryDomainSummaryResolver` | `resources.services.directorySummaries(access)` |
|
|
| Identity | validación de sesión existente | `identity.services.sessions.isAuthenticated` |
|
|
| Companies | proyección de existencia | `companies.existence.exists` |
|
|
|
|
Las clases propietarias permanecen privadas; los módulos exponen sus instancias
|
|
mediante composición. El paquete común no depende de Sequelize, Express ni de
|
|
módulos funcionales. Las nuevas dependencias son enlaces workspace a contratos y,
|
|
en el servidor, al `Result` compartido existente. No se añade una biblioteca externa.
|
|
|
|
## Contexto y seguridad
|
|
|
|
`SessionRelationshipReadAccess.createContext(session, companyId, requestId)` recibe
|
|
la sesión del servidor, valida su usuario activo y `authVersion` mediante Identity
|
|
y comprueba la existencia de la compañía mediante Companies. Emite un contexto
|
|
inmutable y conserva su asociación con la sesión en un WeakMap privado.
|
|
|
|
Cada consulta o lote vuelve a autorizar **antes** de leer datos propietarios. Una
|
|
copia de sus campos, un contexto inventado o uno emitido por otra instancia no son
|
|
credenciales válidas. Una desactivación o cambio de `authVersion` invalida lecturas
|
|
posteriores. El consumidor debe usar contextos de petición y no almacenarlos como
|
|
una autorización permanente.
|
|
|
|
El modelo actual permite a los operadores autenticados acceder a las compañías
|
|
atendidas en general; no dispone de ACL granular por compañía ni por entidad.
|
|
La validación de existencia no crea esa ACL ni exige compañía activa. La compañía
|
|
atendida tampoco es un tenant del operador. La autorización compartida cubre ambos
|
|
extremos en este modelo; si aparecen permisos más finos, los propietarios deberán
|
|
aplicarlos también antes de paginar y resolver nombres.
|
|
|
|
Todas las consultas SQL filtran por compañía. Una referencia que declara otra
|
|
compañía produce `FORBIDDEN` en el proveedor. Un UUID ajeno presentado dentro del
|
|
ámbito local produce el mismo `UNAVAILABLE` que uno inexistente. Los resolutores
|
|
producen `unavailable` sin nombres ni motivos para entidades ajenas, inexistentes,
|
|
proveedores/tipos incompatibles o recursos sin capacidad de dominio. Una referencia
|
|
estructuralmente inválida o un lote mayor de 100 produce `INVALID_QUERY`.
|
|
|
|
No se acepta un operador desde HTTP: este paso no añade rutas. La fábrica de
|
|
contextos es una API interna del servidor, no una credencial transportable del
|
|
navegador. Los fallos técnicos de infraestructura se propagan al diagnóstico del
|
|
servidor; no se convierten silenciosamente en listas vacías o entidades ausentes.
|
|
|
|
## Consultas directas e inversas
|
|
|
|
El consumidor obtiene el contexto con el objeto de sesión del servidor y consulta
|
|
`runtime.relationships.memberships.list(query, context)`. `query` contiene:
|
|
|
|
- `typeId: "assets:directory-membership"`.
|
|
- `anchor`: referencia UUID de un activo o recurso de dominio de la compañía.
|
|
- `direction`: `outgoing` desde activo, `incoming` desde dominio o `either` desde
|
|
cualquiera. Un sentido incompatible produce `INVALID_QUERY`.
|
|
- `includeEnded`: `false` limita a vigentes; `true` incluye periodos finalizados.
|
|
- `pageSize`: entero entre 1 y 100; `cursor` opcional para continuar.
|
|
|
|
La consulta inversa conserva `source = activo`, `target = recurso de dominio`.
|
|
`origin.kind = record` y `origin.id` es el UUID de la pertenencia existente.
|
|
`stateCode` vale `active` o `ended`; no depende de que el recurso siga activo.
|
|
El UUID y rol de un periodo finalizado permanecen consultables. Un traslado devuelve
|
|
el nuevo periodo al consultar vigentes y ambos al incluir finalizadas; cambiar rol
|
|
mantiene el UUID, como en los comandos actuales.
|
|
|
|
El proveedor valida existencia del activo en Assets. Para el ancla inversa utiliza
|
|
el resolutor público de Resources, que exige una proyección estructurada de dominio.
|
|
No importa repositorios privados ni consulta tablas privadas de Resources. Las FK
|
|
compuestas del paso 2.2 garantizan compañía, existencia y capacidad del destino de
|
|
las filas devueltas desde un activo.
|
|
|
|
## Decisión sobre el rol
|
|
|
|
El contrato común no tenía atributos de relación. Usar solamente la consulta
|
|
especializada anterior de Assets obligaría al consumidor común a volver a consultar
|
|
las pertenencias para conocer su rol, con dos lecturas potencialmente desfasadas.
|
|
Añadir un campo exclusivo a un subtipo también haría que el schema común estricto
|
|
rechazara sus respuestas.
|
|
|
|
Se añade **`roleCode?: string`**, un código semántico validado como texto no vacío,
|
|
siguiendo el criterio de `stateCode`. Las vistas anteriores sin rol siguen siendo
|
|
válidas; no se admiten objetos arbitrarios de metadatos. Assets publica
|
|
`DirectoryMembershipRelationshipSchema` y su página, que restringen tipo,
|
|
representación, extremos, estado y `roleCode` al vocabulario existente
|
|
`member | domain-controller`. La raíz persistida sigue llamando a su campo `role`;
|
|
este cambio solo adapta lectura.
|
|
|
|
El resultado satisface `RelationshipQueryService` y pasa tanto el schema común de
|
|
página como el especializado. Un consumidor genérico conserva `roleCode`; uno que
|
|
necesite la unión cerrada valida con el schema público de Assets. Ningún consumidor
|
|
obtiene permisos de escritura por disponer de estos contratos.
|
|
|
|
## Paginación SQL e índices
|
|
|
|
La raíz se consulta con `WHERE companyId = ...` y `assetId = ...` o
|
|
`domainResourceId = ...`, `endedAt IS NULL` cuando corresponde,
|
|
`id > :afterId` en páginas sucesivas, `ORDER BY id ASC LIMIT :pageSizePlusOne`.
|
|
Un JOIN con Assets conserva el ámbito del origen. Solo se retira la fila centinela
|
|
en memoria; nunca se carga el conjunto completo ni sus revisiones.
|
|
|
|
Se utiliza `directory_membership_asset(companyId, assetId, id)` para la consulta
|
|
directa. El índice previo del dominio antepone `role, endedAt`, por lo que no ofrece
|
|
el mismo orden al consultar todos los roles. La migración explícita e idempotente
|
|
`directory-membership-read-index.ts` añade
|
|
`directory_membership_domain_page(companyId, domainResourceId, id)`, sin cambiar
|
|
columnas, restricciones, comandos ni persistencia de dominios. El índice previo se
|
|
conserva para los filtros especializados existentes. La migración se ejecuta después
|
|
de inicializar las tablas de pertenencias.
|
|
|
|
El cursor usa HMAC SHA-256 con una clave aleatoria privada de la instancia del
|
|
proveedor. Incluye última identidad y versión de formato; queda ligado al operador,
|
|
compañía, tipo, proveedor/tipo/UUID del ancla, sentido, filtro histórico y tamaño de
|
|
página. Se valida forma, firma y coincidencia de ámbito antes de consultar filas.
|
|
Un cursor inválido o reutilizado en otra consulta produce `INVALID_QUERY`.
|
|
El identificador de petición puede cambiar entre páginas.
|
|
|
|
La firma protege integridad, no confidencialidad: su contenido solo contiene IDs y
|
|
opciones ya conocidos por el consumidor. No contiene nombres, totales ni filas
|
|
ocultas. Los cursores caducan al reiniciar y no se comparten entre instancias del
|
|
servidor; habría que acordar una clave compartida si se despliega con varios nodos.
|
|
El recorrido no es una instantánea transaccional: cambios concurrentes pueden
|
|
cambiar el conjunto vigente, aunque el orden por UUID se mantiene estable.
|
|
|
|
## Resolución por lotes
|
|
|
|
`assetSummaries.resolveMany` proyecta `id, name, status, templateName` de Assets;
|
|
la plantilla es el subtítulo y el estado conserva su código y clave de traducción.
|
|
`domainSummaries.resolveMany` proyecta nombre, estado y DNS normalizado de Resources
|
|
mediante JOIN con su propia proyección de dominios. No carga inventarios completos,
|
|
características ni revisiones.
|
|
|
|
Cada resolutor acepta hasta 100 referencias, valida forma y ámbito, deduplica IDs
|
|
para una sola consulta SQL y reconstruye resultados en el orden de entrada,
|
|
incluyendo repeticiones. Un lote vacío o sin referencias elegibles no consulta
|
|
entidades. La revalidación de sesión y compañía tiene un coste fijo por operación,
|
|
independiente del número de referencias. No se cachean nombres entre compañías.
|
|
|
|
## HTTP, segundo consumidor y ownership
|
|
|
|
No se añaden endpoints: la integración interna comprueba el contrato real sin
|
|
adelantar la UI del paso 2.4 ni crear una API universal de grafos. Los endpoints
|
|
especializados del paso 2.2 permanecen intactos.
|
|
|
|
Un segundo consumidor interno recibe explícitamente el servicio de consultas y los
|
|
resolutores que necesite. Para la UI del paso 2.4 deberá añadirse un adaptador HTTP
|
|
acotado que obtenga sesión y requestId del servidor, valide el ámbito y parámetros
|
|
con Zod y traduzca estos errores a HTTP. El navegador no enviará contextos ni actores.
|
|
La disposición de pantallas todavía no está implementada.
|
|
|
|
Un segundo **proveedor** deberá aportar su definición estática, adaptador de lectura,
|
|
resolutores y composición explícita. Sus tablas, identidad, reglas de capacidad,
|
|
autorización, transacciones, cardinalidades, revisiones, traducciones y comandos
|
|
siguen siendo responsabilidad del propietario. El registro no ejecuta servicios ni
|
|
escribe relaciones; añadir una definición no implementa esas responsabilidades.
|
|
|
|
## Pruebas y alcance verificado
|
|
|
|
`typed-relationships.integration.test.ts` utiliza exclusivamente `metis_desk_test`,
|
|
crea datos con UUID propios mediante los casos de uso reales y los limpia por ámbito.
|
|
Verifica ambos sentidos, roles, ausencia de duplicados, cambios de rol, traslado,
|
|
finalización, historial, cursores ligados a consulta, límites, lotes, orden,
|
|
referencias no disponibles y revocación de contextos. Un hook SQL de la propia
|
|
prueba cuenta consultas de proyección y comprueba LIMIT y keyset reales.
|
|
|
|
La inspección EXPLAIN usa los predicados y ordenación de la raíz para ambos extremos,
|
|
con y sin finalizadas. Se conserva la decisión libre del optimizador y se comprueba
|
|
por separado cada índice compuesto con FORCE INDEX solo en EXPLAIN; las consultas
|
|
de producción no fuerzan índices. La muestra es funcional y pequeña, no una prueba
|
|
de carga ni una medida de rendimiento. Los resultados observados y los comandos
|
|
ejecutados se registran a continuación.
|
|
|
|
Pruebas adicionales: contratos comunes, definición real, schemas propietarios,
|
|
firma y vinculación del cursor y autorización de contexto; regresiones completas
|
|
de Assets y Resources y las integraciones de los pasos 2.1 y 2.2. Las pruebas no
|
|
cambian las expectativas de funcionalidades ajenas.
|
|
|
|
### Resultado de verificación del 20 de octubre de 2026
|
|
|
|
- Typecheck de contratos, Assets, Resources, Identity, Companies y servidor: correcto.
|
|
El typecheck global también se intentó: falla en el fichero vendor sin modificar
|
|
`packages/shadcn-ui/src/components/calendar.tsx:165`, por incompatibilidad de
|
|
`locale` con `exactOptionalPropertyTypes` (TS2375). No se modifica vendor.
|
|
- Build del servidor, Biome y ESLint dirigidos, y `git diff --check`: correctos.
|
|
- Contratos comunes: **13/13**.
|
|
- Suite completa de Assets: **119/119**, incluyendo tres pruebas nuevas del proveedor.
|
|
- Suite completa de Resources: **53/53**.
|
|
- Suite completa de Identity: **10/10**; contexto de lectura del servidor: **1/1**.
|
|
- MariaDB, pasos 2.1, 2.2 y 2.3: **10/10**, incluyendo siete subpruebas del proveedor
|
|
y su prueba contenedora. Migraciones y fixtures solo sobre `metis_desk_test`.
|
|
- Companies: **169/171**. Se reproducen los dos fallos ya conocidos en
|
|
`modules/companies/src/web/companies/shared/companies-page.test.tsx`, líneas 180
|
|
y 210: expectativas de texto «Personas (1)» y «Esta persona no tiene acceso a
|
|
Metis Desk». No se modifican estas pruebas ni la UI para resolverlos.
|
|
|
|
En los cuatro EXPLAIN libres de la muestra funcional, MariaDB eligió `PRIMARY`,
|
|
acceso `range`, con `Using where`. Al comprobar expresamente cada índice compuesto,
|
|
aparecen `directory_membership_asset` y `directory_membership_domain_page` con
|
|
`Using index condition`, además de `Using where` cuando se filtran finalizadas;
|
|
ninguno requiere `filesort`. Estos resultados no predicen el plan con volúmenes
|
|
reales ni justifican forzar índices en el proveedor.
|
|
|
|
Comandos reproducibles desde la raíz, usando las versiones fijadas del proyecto:
|
|
|
|
```bash
|
|
npm_config_verify_deps_before_run=false pnpm --config.verify-deps-before-run=false exec turbo run typecheck --env-mode=loose
|
|
pnpm --config.verify-deps-before-run=false --filter @metis/relationship-contracts --filter @metis/assets --filter @metis/resources --filter @metis/identity --filter @metis/companies --filter @metis/server typecheck
|
|
pnpm --config.verify-deps-before-run=false --filter @metis/relationship-contracts test
|
|
pnpm --config.verify-deps-before-run=false --filter @metis/server test:relationships
|
|
pnpm --config.verify-deps-before-run=false --filter @metis/server build
|
|
git diff --check
|
|
```
|
|
|
|
El intento inicial del comando global mediante PNPM/Turbo tropezó antes con la
|
|
comprobación automática de dependencias de los procesos hijos y su store SQLite.
|
|
La variable de configuración y `--env-mode=loose` permitieron ejecutar los
|
|
compiladores, que identificaron el error vendor indicado. No se cambia la
|
|
configuración del proyecto ni se relajan sus tipos.
|
|
|
|
Las suites de Assets, Resources, Identity y Companies ejecutaron **todos** los
|
|
ficheros de sus scripts `test`, añadiendo `--test-isolation=none` al runner nativo
|
|
para enumerar los casos reales, sin quitar pruebas. El contrato nuevo y la prueba
|
|
de autorización también se ejecutaron directamente con `node --import tsx --test
|
|
--test-isolation=none`.
|
|
|
|
La integración conjunta se ejecutó desde `apps/server`:
|
|
|
|
```bash
|
|
DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx \
|
|
--test --test-isolation=none --test-concurrency=1 \
|
|
src/directory-domains.integration.test.ts \
|
|
src/asset-directory-memberships.integration.test.ts \
|
|
src/typed-relationships.integration.test.ts
|
|
```
|
|
|
|
Biome y ESLint se aplicaron a los 24 ficheros TypeScript de este paso y Biome a los
|
|
manifiestos modificados. La comprobación del grafo workspace recorre 13 paquetes sin
|
|
ciclos. No se ejecuta la suite global del monorepo ni se afirma que esté verde.
|
|
|
|
## Archivos de este paso
|
|
|
|
Creados:
|
|
|
|
- `apps/server/src/infrastructure/http/relationship-runtime.ts`.
|
|
- `apps/server/src/infrastructure/http/relationship-read-access.ts` y su prueba.
|
|
- `apps/server/src/typed-relationships.integration.test.ts`.
|
|
- `modules/assets/src/common/directory-membership-relationship.ts`.
|
|
- En `modules/assets/src/api/infrastructure/persistence/`:
|
|
`directory-membership-relationship-query.ts`, `directory-relationship-cursor.ts`,
|
|
`asset-entity-summary-resolver.ts`, `directory-membership-read-index.ts` y
|
|
`directory-membership-provider.test.ts`.
|
|
- `modules/resources/src/api/infrastructure/persistence/directory-domain-summary-resolver.ts`.
|
|
- `modules/companies/src/api/infrastructure/persistence/company-existence-reader.ts`.
|
|
- Este documento.
|
|
|
|
Modificados:
|
|
|
|
- Schemas, interfaces, registro, exports y pruebas de `packages/relationship-contracts`.
|
|
- Composition roots de Assets, Resources, Identity y Companies.
|
|
- `apps/server/src/infrastructure/http/identity-runtime.ts` y el fixture compartido
|
|
de pertenencias para reutilizar la composición real en integración.
|
|
- Exports comunes de Assets, scripts/manifiestos de Assets, Resources y servidor,
|
|
y `pnpm-lock.yaml` con enlaces workspace, sin paquetes externos nuevos.
|
|
- [Documentación del contrato](contratos-de-relaciones-tipadas.md).
|
|
|
|
Los cambios pendientes de los pasos anteriores permanecen en el árbol de trabajo;
|
|
este paso no los revierte ni los presenta como modificaciones nuevas.
|
|
|
|
## Consumidor visual incorporado en el paso 2.4
|
|
|
|
El [adaptador HTTP y las fichas de dominios/activos](../integracion-visual-de-dominios-windows.md)
|
|
consumen ya estos servicios. Esta incorporación no cambia el contrato común, la
|
|
persistencia ni la estrategia HMAC descritos para el paso 2.3.
|